diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 8cbd9098..51fa5f4c 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -59,7 +59,7 @@ jobs: echo "$json" > google-services.json - name: 🏗️ EAS Build - run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive + run: yarn use-build-number-with-bump eas build -p android --profile production --local --output build.aab --non-interactive - name: 🚀 Deploy run: eas submit -p android --non-interactive --path build.aab diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml index f5188b4b..c9752d86 100644 --- a/.github/workflows/build-submit-ios.yml +++ b/.github/workflows/build-submit-ios.yml @@ -2,14 +2,13 @@ name: Build and Submit iOS on: - schedule: - - cron: '0 5 * * *' workflow_dispatch: inputs: profile: type: choice description: Build profile to use options: + - testflight - production jobs: @@ -69,7 +68,7 @@ jobs: echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json - name: 🏗️ EAS Build - run: yarn use-build-number eas build -p ios --profile production --local --output build.ipa --non-interactive + run: yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive - name: 🚀 Deploy run: eas submit -p ios --non-interactive --path build.ipa diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index 72a38eaa..1c7e57e5 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -4,6 +4,12 @@ name: Bundle and Deploy EAS Update on: workflow_dispatch: inputs: + channel: + type: choice + description: Deployment channel to use + options: + - testflight + - production runtimeVersion: type: string description: Runtime version (in x.x.x format) that this update is for @@ -13,13 +19,48 @@ jobs: bundleDeploy: name: Bundle and Deploy EAS Update runs-on: ubuntu-latest + outputs: + fingerprint-diff: ${{ steps.fingerprint.outputs.fingerprint-diff }} steps: + - name: Check for EXPO_TOKEN + run: > + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then + echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" + exit 1 + fi + + # Validate the version if one is supplied. This should generally happen if the update is for a production client - name: 🧐 Validate version + if: ${{ inputs.runtimeVersion }} run: | - [[ "${{ github.event.inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1 + if [ -z "${{ inputs.runtimeVersion }}" ]; then + [[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1 + fi - name: ⬇️ Checkout uses: actions/checkout@v4 + with: + fetch-depth: 100 + + - name: ⬇️ Fetch commits from base branch + run: git fetch origin main:main --depth 100 + + # This should get the current production release's commit's hash to see if the update is compatible + - name: 🕵️ Get the base commit + id: base-commit + run: | + if [ -z "${{ inputs.channel == 'production' }}" ]; then + echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT" + else + echo base-commit=$(git log -n 1 --skip 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT" + fi + + - name: ✓ Make sure we found a base commit + run: | + if [ -z "${{ steps.base-commit.outputs.base-commit }}" ]; then + echo "Could not find a base commit for this release. Exiting." + exit 1 + fi - name: 🔧 Setup Node uses: actions/setup-node@v4 @@ -30,13 +71,167 @@ jobs: - name: ⚙️ Install Dependencies run: yarn install - - name: 🪛 Install jq - uses: dcarbone/install-jq-action@v2 + # Run the fingerprint + - name: 📷 Check fingerprint + id: fingerprint + uses: expo/expo-github-action/fingerprint@main + with: + previous-git-commit: ${{ steps.base-commit.outputs.base-commit }} + + - name: 👀 Debug fingerprint + run: | + echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}" + echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}" + + - name: 🔨 Setup EAS + uses: expo/expo-github-action@v8 + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} - name: ⛏️ Setup Expo + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} run: yarn global add eas-cli-local-build-plugin + - name: 🪛 Setup jq + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + uses: dcarbone/install-jq-action@v2 + - name: 🔤 Compile Translations + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + run: yarn intl:build + + - name: ✏️ Write environment variables + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + run: | + export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' + echo "${{ secrets.ENV_TOKEN }}" > .env + echo "$json" > google-services.json + + - name: 🏗️ Create Bundle + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + run: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export + + - name: 📦 Package Bundle and 🚀 Deploy + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} + run: yarn use-build-number bash scripts/bundleUpdate.sh + env: + DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }} + RUNTIME_VERSION: ${{ inputs.runtimeVersion }} + CHANNEL_NAME: ${{ inputs.channel || 'testflight' }} + + # GitHub actions are horrible so let's just copy paste this in + buildIfNecessaryIOS: + name: Build and Submit iOS + runs-on: macos-14 + needs: [bundleDeploy] + # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be + # available here + if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-diff != '[]' }} + steps: + - name: Check for EXPO_TOKEN + run: > + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then + echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" + exit 1 + fi + + - name: ⬇️ Checkout + uses: actions/checkout@v4 + + - name: 🔧 Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: 🔨 Setup EAS + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: ⛏️ Setup EAS local builds + run: yarn global add eas-cli-local-build-plugin + + - name: ⚙️ Install dependencies + run: yarn install + + - name: ☕️ Setup Cocoapods + uses: maxim-lobanov/setup-cocoapods@v1 + with: + version: 1.14.3 + + - name: 💾 Cache Pods + uses: actions/cache@v3 + id: pods-cache + with: + path: ./ios/Pods + # We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not + # change unless the yarn version changes as well. + key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }} + + - name: 🔤 Compile translations + run: yarn intl:build + + - name: ✏️ Write environment variables + run: | + echo "${{ secrets.ENV_TOKEN }}" > .env + echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json + + - name: 🏗️ EAS Build + run: yarn use-build-number-with-bump eas build -p ios --profile testflight --local --output build.ipa --non-interactive + + - name: 🚀 Deploy + run: eas submit -p ios --non-interactive --path build.ipa + + buildIfNecessaryAndroid: + name: Build and Submit Android + runs-on: ubuntu-latest + needs: [ bundleDeploy ] + # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be + # available here + if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-diff != '[]' }} + + steps: + - name: Check for EXPO_TOKEN + run: > + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then + echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions" + exit 1 + fi + + - name: ⬇️ Checkout + uses: actions/checkout@v4 + + - name: 🔧 Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: yarn + + - name: 🔨 Setup EAS + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: ⛏️ Setup EAS local builds + run: yarn global add eas-cli-local-build-plugin + + - uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: ⚙️ Install dependencies + run: yarn install + + - name: 🔤 Compile translations run: yarn intl:build - name: ✏️ Write environment variables @@ -45,11 +240,31 @@ jobs: echo "${{ secrets.ENV_TOKEN }}" > .env echo "$json" > google-services.json - - name: 🏗️ Create Bundle - run: yarn export + - name: 🏗️ EAS Build + run: yarn use-build-number-with-bump eas build -p android --profile testflight-android --local --output build.apk --non-interactive - - name: 📦 Package Bundle and 🚀 Deploy - run: yarn make-deploy-bundle + - name: ⏰ Get a timestamp + id: timestamp + uses: nanzm/get-time-action@master + with: + format: 'MM-DD-HH-mm-ss' + + - name: 🚀 Upload Artifact + id: upload-artifact + uses: actions/upload-artifact@v4 + with: + retention-days: 30 + compression-level: 0 + name: build-${{ steps.timestamp.outputs.time }}.apk + path: build.apk + + - name: 🔔 Notify Slack + uses: slackapi/slack-github-action@v1.25.0 + with: + payload: | + { + "text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}" + } env: - DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }} - RUNTIME_VERSION: ${{ github.event.inputs.runtimeVersion }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK diff --git a/.gitignore b/.gitignore index ddb553d2..77dbd00f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ xcuserdata *.moved-aside DerivedData *.hmap -*.ipa *.xcuserstate # Android/IntelliJ @@ -110,3 +109,8 @@ google-services.json # i18n src/locale/locales/_build/ src/locale/locales/**/*.js + +# local builds +*.apk +*.aab +*.ipa diff --git a/app.config.js b/app.config.js index c151862a..21b79491 100644 --- a/app.config.js +++ b/app.config.js @@ -41,6 +41,9 @@ module.exports = function (config) { : process.env.BSKY_IOS_BUILD_NUMBER const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' + const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' + + const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' return { expo: { @@ -122,10 +125,20 @@ module.exports = function (config) { favicon: './assets/favicon.png', }, updates: { - enabled: true, - fallbackToCacheTimeout: 1000, - url: 'https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302', + url: 'https://updates.bsky.app/manifest', + // TODO Eventually we want to enable this for all environments, but for now it will only be used for + // TestFlight builds + enabled: IS_TESTFLIGHT, + fallbackToCacheTimeout: 30000, + codeSigningCertificate: './code-signing/certificate.pem', + codeSigningMetadata: { + keyid: 'main', + alg: 'rsa-v1_5-sha256', + }, + checkAutomatically: 'NEVER', + channel: UPDATES_CHANNEL, }, + assetBundlePatterns: ['**/*'], plugins: [ 'expo-localization', Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo', @@ -145,12 +158,6 @@ module.exports = function (config) { }, }, ], - [ - 'expo-updates', - { - username: 'blueskysocial', - }, - ], [ 'expo-notifications', { diff --git a/eas.json b/eas.json index 2b4c7cb6..ed647dbb 100644 --- a/eas.json +++ b/eas.json @@ -16,14 +16,20 @@ "ios": { "simulator": true, "resourceClass": "large" + }, + "env": { + "EXPO_PUBLIC_ENV": "production" } }, "preview": { "extends": "base", "distribution": "internal", - "channel": "preview", + "channel": "production", "ios": { "resourceClass": "large" + }, + "env": { + "EXPO_PUBLIC_ENV": "production" } }, "production": { @@ -35,9 +41,12 @@ "android": { "autoIncrement": true }, - "channel": "production" + "channel": "production", + "env": { + "EXPO_PUBLIC_ENV": "production" + } }, - "github": { + "testflight": { "extends": "base", "ios": { "autoIncrement": true @@ -45,7 +54,24 @@ "android": { "autoIncrement": true }, - "channel": "production" + "channel": "testflight", + "env": { + "EXPO_PUBLIC_ENV": "testflight" + } + }, + "testflight-android": { + "extends": "base", + "distribution": "internal", + "ios": { + "autoIncrement": true + }, + "android": { + "autoIncrement": true + }, + "channel": "testflight", + "env": { + "EXPO_PUBLIC_ENV": "testflight" + } } }, "submit": { diff --git a/package.json b/package.json index c51d6f22..b4018463 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.75.0", + "version": "1.76.0", "private": true, "engines": { "node": ">=18" @@ -14,11 +14,12 @@ "ios": "expo run:ios", "web": "expo start --web", "use-build-number": "./scripts/useBuildNumberEnv.sh", + "use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh", "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/", - "build-all": "yarn intl:build && yarn use-build-number eas build --platform all", - "build-ios": "yarn use-build-number eas build -p ios", - "build-android": "yarn use-build-number eas build -p android", - "build": "yarn use-build-number eas build", + "build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all", + "build-ios": "yarn use-build-number-with-bump eas build -p ios", + "build-android": "yarn use-build-number-with-bump eas build -p android", + "build": "yarn use-build-number-with-bump eas build", "start": "expo start --dev-client", "start:prod": "expo start --dev-client --no-dev --minify", "clean-cache": "rm -rf node_modules/.cache/babel-loader/*", @@ -43,8 +44,7 @@ "intl:compile": "lingui compile", "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android", "update-extensions": "bash scripts/updateExtensions.sh", - "export": "npx expo export", - "make-deploy-bundle": "bash scripts/bundleUpdate.sh" + "export": "npx expo export" }, "dependencies": { "@atproto/api": "^0.12.2", diff --git a/patches/expo-updates+0.24.7.patch b/patches/expo-updates+0.24.7.patch new file mode 100644 index 00000000..603ae32e --- /dev/null +++ b/patches/expo-updates+0.24.7.patch @@ -0,0 +1,26 @@ +diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift +index 189a5f5..8d5b8e6 100644 +--- a/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift ++++ b/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift +@@ -68,13 +68,20 @@ public final class NewUpdate: Update { + processedAssets.append(asset) + } + ++ // Instead of relying on various hacks to get the correct format for the specific ++ // platform on the backend, we can just add this little patch.. ++ let dateFormatter = DateFormatter() ++ dateFormatter.locale = Locale(identifier: "en_US_POSIX") ++ dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" ++ let date = dateFormatter.date(from:commitTime) ?? RCTConvert.nsDate(commitTime)! ++ + return Update( + manifest: manifest, + config: config, + database: database, + updateId: uuid, + scopeKey: config.scopeKey, +- commitTime: RCTConvert.nsDate(commitTime), ++ commitTime: date, + runtimeVersion: runtimeVersion, + keep: true, + status: UpdateStatus.StatusPending, diff --git a/patches/expo-updates+0.24.7.patch.md b/patches/expo-updates+0.24.7.patch.md new file mode 100644 index 00000000..8a884812 --- /dev/null +++ b/patches/expo-updates+0.24.7.patch.md @@ -0,0 +1,7 @@ +# Expo-Updates Patch + +This is a small patch to convert timestamp formats that are returned from the backend. Instead of relying on the +backend to return the correct format for a specific format (the format required on Android is not the same as on iOS) +we can just add this conversion in. + +Don't remove unless we make changes on the backend to support both platforms. \ No newline at end of file diff --git a/scripts/bundleUpdate.sh b/scripts/bundleUpdate.sh index 18db81a2..5927a36c 100644 --- a/scripts/bundleUpdate.sh +++ b/scripts/bundleUpdate.sh @@ -9,10 +9,13 @@ rm -rf bundle.tar.gz echo "Creating tarball..." node scripts/bundleUpdate.js -cd bundleTempDir || exit +if [ -z "$RUNTIME_VERSION" ]; then + RUNTIME_VERSION=$(cat package.json | jq '.version' -r) +fi +cd bundleTempDir || exit BUNDLE_VERSION=$(date +%s) -DEPLOYMENT_URL="https://updates.bsky.app/v1/upload?runtime-version=$RUNTIME_VERSION&bundle-version=$BUNDLE_VERSION" +DEPLOYMENT_URL="https://updates.bsky.app/v1/upload?runtime-version=$RUNTIME_VERSION&bundle-version=$BUNDLE_VERSION&channel=$CHANNEL_NAME&ios-build-number=$BSKY_IOS_BUILD_NUMBER&android-build-number=$BSKY_ANDROID_VERSION_CODE" tar czvf bundle.tar.gz ./* diff --git a/scripts/useBuildNumberEnv.sh b/scripts/useBuildNumberEnv.sh index fe273d39..2251c090 100755 --- a/scripts/useBuildNumberEnv.sh +++ b/scripts/useBuildNumberEnv.sh @@ -1,11 +1,7 @@ #!/bin/bash outputIos=$(eas build:version:get -p ios) outputAndroid=$(eas build:version:get -p android) -currentIosVersion=${outputIos#*buildNumber - } -currentAndroidVersion=${outputAndroid#*versionCode - } - -BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1)) -BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1)) +BSKY_IOS_BUILD_NUMBER=${outputIos#*buildNumber - } +BSKY_ANDROID_VERSION_CODE=${outputAndroid#*versionCode - } bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*" - diff --git a/scripts/useBuildNumberEnvWithBump.sh b/scripts/useBuildNumberEnvWithBump.sh new file mode 100755 index 00000000..fe273d39 --- /dev/null +++ b/scripts/useBuildNumberEnvWithBump.sh @@ -0,0 +1,11 @@ +#!/bin/bash +outputIos=$(eas build:version:get -p ios) +outputAndroid=$(eas build:version:get -p android) +currentIosVersion=${outputIos#*buildNumber - } +currentAndroidVersion=${outputAndroid#*versionCode - } + +BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1)) +BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1)) + +bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*" + diff --git a/src/App.native.tsx b/src/App.native.tsx index d6e726a5..2c880f21 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -19,6 +19,7 @@ import {init as initPersistedState} from '#/state/persisted' import * as persisted from '#/state/persisted' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {useIntentHandler} from 'lib/hooks/useIntentHandler' +import {useOTAUpdates} from 'lib/hooks/useOTAUpdates' import * as notifications from 'lib/notifications/notifications' import { asyncStoragePersister, @@ -60,6 +61,7 @@ function InnerApp() { const theme = useColorModeTheme() const {_} = useLingui() useIntentHandler() + useOTAUpdates() // init useEffect(() => { diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx index 383d1b95..dd07cafa 100644 --- a/src/components/ReportDialog/SelectLabelerView.tsx +++ b/src/components/ReportDialog/SelectLabelerView.tsx @@ -1,18 +1,16 @@ import React from 'react' import {View} from 'react-native' +import {AppBskyLabelerDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {AppBskyLabelerDefs} from '@atproto/api' export {useDialogControl as useReportDialogControl} from '#/components/Dialog' import {getLabelingServiceTitle} from '#/lib/moderation' - -import {atoms as a, useTheme, useBreakpoints} from '#/alf' -import {Text} from '#/components/Typography' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, useButtonContext} from '#/components/Button' import {Divider} from '#/components/Divider' import * as LabelingServiceCard from '#/components/LabelingServiceCard' - +import {Text} from '#/components/Typography' import {ReportDialogProps} from './types' export function SelectLabelerView({ diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx index 54844cfd..c6769834 100644 --- a/src/components/ReportDialog/SelectReportOptionView.tsx +++ b/src/components/ReportDialog/SelectReportOptionView.tsx @@ -1,16 +1,15 @@ import React from 'react' import {View} from 'react-native' +import {AppBskyLabelerDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {AppBskyLabelerDefs} from '@atproto/api' -import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions' -import {DMCA_LINK} from '#/components/ReportDialog/const' +import {ReportOption, useReportOptions} from '#/lib/moderation/useReportOptions' import {Link} from '#/components/Link' +import {DMCA_LINK} from '#/components/ReportDialog/const' export {useDialogControl as useReportDialogControl} from '#/components/Dialog' -import {atoms as a, useTheme, useBreakpoints} from '#/alf' -import {Text} from '#/components/Typography' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import { Button, ButtonIcon, @@ -19,11 +18,11 @@ import { } from '#/components/Button' import {Divider} from '#/components/Divider' import { - ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, + ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, } from '#/components/icons/Chevron' import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' - +import {Text} from '#/components/Typography' import {ReportDialogProps} from './types' export function SelectReportOptionView({ diff --git a/src/components/moderation/LabelPreference.tsx b/src/components/moderation/LabelPreference.tsx index 7d4bd9c3..028bd1a3 100644 --- a/src/components/moderation/LabelPreference.tsx +++ b/src/components/moderation/LabelPreference.tsx @@ -1,22 +1,21 @@ import React from 'react' import {View} from 'react-native' import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' -import {useLingui} from '@lingui/react' import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' +import {getLabelStrings} from '#/lib/moderation/useLabelInfo' import { usePreferencesQuery, usePreferencesSetContentLabelMutation, } from '#/state/queries/preferences' -import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' -import {getLabelStrings} from '#/lib/moderation/useLabelInfo' - -import {useTheme, atoms as a, useBreakpoints} from '#/alf' -import {Text} from '#/components/Typography' -import {InlineLink} from '#/components/Link' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' import * as ToggleButton from '#/components/forms/ToggleButton' +import {InlineLink} from '#/components/Link' +import {Text} from '#/components/Typography' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo' export function Outer({children}: React.PropsWithChildren<{}>) { return ( diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts index 3f026d3f..3071e031 100644 --- a/src/lib/app-info.ts +++ b/src/lib/app-info.ts @@ -1,5 +1,9 @@ import VersionNumber from 'react-native-version-number' -import * as Updates from 'expo-updates' -export const updateChannel = Updates.channel -export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})` +export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' +export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' + +const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' +export const appVersion = `${VersionNumber.appVersion} (${ + VersionNumber.buildVersion +}, ${IS_DEV ? 'development' : UPDATES_CHANNEL})` diff --git a/src/lib/hooks/useOTAUpdates.ts b/src/lib/hooks/useOTAUpdates.ts new file mode 100644 index 00000000..181f0b2c --- /dev/null +++ b/src/lib/hooks/useOTAUpdates.ts @@ -0,0 +1,142 @@ +import React from 'react' +import {Alert, AppState, AppStateStatus} from 'react-native' +import app from 'react-native-version-number' +import { + checkForUpdateAsync, + fetchUpdateAsync, + isEnabled, + reloadAsync, + setExtraParamAsync, + useUpdates, +} from 'expo-updates' + +import {logger} from '#/logger' +import {IS_TESTFLIGHT} from 'lib/app-info' +import {isIOS} from 'platform/detection' + +const MINIMUM_MINIMIZE_TIME = 15 * 60e3 + +async function setExtraParams() { + await setExtraParamAsync( + isIOS ? 'ios-build-number' : 'android-build-number', + // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is. + // This just ensures it gets passed as a string + `${app.buildVersion}`, + ) + await setExtraParamAsync( + 'channel', + IS_TESTFLIGHT ? 'testflight' : 'production', + ) +} + +export function useOTAUpdates() { + const appState = React.useRef('active') + const lastMinimize = React.useRef(0) + const ranInitialCheck = React.useRef(false) + const timeout = React.useRef() + const {isUpdatePending} = useUpdates() + + const setCheckTimeout = React.useCallback(() => { + timeout.current = setTimeout(async () => { + try { + await setExtraParams() + + logger.debug('Checking for update...') + const res = await checkForUpdateAsync() + + if (res.isAvailable) { + logger.debug('Attempting to fetch update...') + await fetchUpdateAsync() + } else { + logger.debug('No update available.') + } + } catch (e) { + logger.warn('OTA Update Error', {error: `${e}`}) + } + }, 10e3) + }, []) + + const onIsTestFlight = React.useCallback(() => { + setTimeout(async () => { + try { + await setExtraParams() + + const res = await checkForUpdateAsync() + if (res.isAvailable) { + await fetchUpdateAsync() + + Alert.alert( + 'Update Available', + 'A new version of the app is available. Relaunch now?', + [ + { + text: 'No', + style: 'cancel', + }, + { + text: 'Relaunch', + style: 'default', + onPress: async () => { + await reloadAsync() + }, + }, + ], + ) + } + } catch (e: any) { + // No need to handle + } + }, 3e3) + }, []) + + React.useEffect(() => { + // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This + // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update + // immediately. + if (IS_TESTFLIGHT) { + onIsTestFlight() + return + } else if (!isEnabled || __DEV__ || ranInitialCheck.current) { + // Development client shouldn't check for updates at all, so we skip that here. + return + } + + setCheckTimeout() + ranInitialCheck.current = true + }, [onIsTestFlight, setCheckTimeout]) + + // After the app has been minimized for 30 minutes, we want to either A. install an update if one has become available + // or B check for an update again. + React.useEffect(() => { + if (!isEnabled) return + + const subscription = AppState.addEventListener( + 'change', + async nextAppState => { + if ( + appState.current.match(/inactive|background/) && + nextAppState === 'active' + ) { + // If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since + // chances are that there isn't anything important going on in the current session. + if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) { + if (isUpdatePending) { + await reloadAsync() + } else { + setCheckTimeout() + } + } + } else { + lastMinimize.current = Date.now() + } + + appState.current = nextAppState + }, + ) + + return () => { + clearTimeout(timeout.current) + subscription.remove() + } + }, [isUpdatePending, setCheckTimeout]) +} diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index 7d991cc7..9d51a619 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -1,51 +1,49 @@ import React from 'react' import {View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {ComAtprotoLabelDefs} from '@atproto/api' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {LABELS} from '@atproto/api' import {useSafeAreaFrame} from 'react-native-safe-area-context' +import {ComAtprotoLabelDefs} from '@atproto/api' +import {LABELS} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types' -import {CenteredView} from '#/view/com/util/Views' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {useAnalytics} from 'lib/analytics/analytics' -import {useSetMinimalShellMode} from '#/state/shell' -import {useSession} from '#/state/session' +import {getLabelingServiceTitle} from '#/lib/moderation' +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {logger} from '#/logger' +import { + useMyLabelersQuery, + usePreferencesQuery, + UsePreferencesQueryResponse, + usePreferencesSetAdultContentMutation, +} from '#/state/queries/preferences' import { useProfileQuery, useProfileUpdateMutation, } from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {useSetMinimalShellMode} from '#/state/shell' +import {useAnalytics} from 'lib/analytics/analytics' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {CenteredView} from '#/view/com/util/Views' import {ScrollView} from '#/view/com/util/Views' - -import { - UsePreferencesQueryResponse, - useMyLabelersQuery, - usePreferencesQuery, - usePreferencesSetAdultContentMutation, -} from '#/state/queries/preferences' - -import {getLabelingServiceTitle} from '#/lib/moderation' -import {logger} from '#/logger' -import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf' +import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' +import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {Divider} from '#/components/Divider' +import * as Toggle from '#/components/forms/Toggle' +import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' +import {Props as SVGIconProps} from '#/components/icons/common' +import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' -import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' -import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' -import {Text} from '#/components/Typography' -import * as Toggle from '#/components/forms/Toggle' -import {InlineLink, Link} from '#/components/Link' -import {Button, ButtonText} from '#/components/Button' -import {Loader} from '#/components/Loader' import * as LabelingService from '#/components/LabelingServiceCard' +import {InlineLink, Link} from '#/components/Link' +import {Loader} from '#/components/Loader' import {GlobalLabelPreference} from '#/components/moderation/LabelPreference' -import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' -import {Props as SVGIconProps} from '#/components/icons/common' -import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' -import * as Dialog from '#/components/Dialog' +import {Text} from '#/components/Typography' function ErrorState({error}: {error: string}) { const t = useTheme() diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx index 2b2b9959..5ba8f00a 100644 --- a/src/screens/Profile/Sections/Labels.tsx +++ b/src/screens/Profile/Sections/Labels.tsx @@ -1,30 +1,29 @@ import React from 'react' import {View} from 'react-native' +import {useSafeAreaFrame} from 'react-native-safe-area-context' import { AppBskyLabelerDefs, - ModerationOpts, - interpretLabelValueDefinitions, InterpretedLabelValueDefinition, + interpretLabelValueDefinitions, + ModerationOpts, } from '@atproto/api' -import {Trans, msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useSafeAreaFrame} from 'react-native-safe-area-context' -import {useScrollHandlers} from '#/lib/ScrollContext' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' -import {ListRef} from '#/view/com/util/List' -import {SectionRef} from './types' +import {useScrollHandlers} from '#/lib/ScrollContext' import {isNative} from '#/platform/detection' - -import {useTheme, atoms as a} from '#/alf' -import {Text} from '#/components/Typography' -import {Loader} from '#/components/Loader' -import {Divider} from '#/components/Divider' +import {ListRef} from '#/view/com/util/List' import {CenteredView, ScrollView} from '#/view/com/util/Views' -import {ErrorState} from '../ErrorState' -import {LabelerLabelPreference} from '#/components/moderation/LabelPreference' +import {atoms as a, useTheme} from '#/alf' +import {Divider} from '#/components/Divider' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Loader} from '#/components/Loader' +import {LabelerLabelPreference} from '#/components/moderation/LabelPreference' +import {Text} from '#/components/Typography' +import {ErrorState} from '../ErrorState' +import {SectionRef} from './types' interface LabelsSectionProps { isLabelerLoading: boolean diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 3967678b..790ce5ee 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -3,72 +3,72 @@ import { ActivityIndicator, Linking, Platform, - StyleSheet, Pressable, + StyleSheet, TextStyle, TouchableOpacity, View, ViewStyle, } from 'react-native' -import {useFocusEffect, useNavigation} from '@react-navigation/native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import * as AppInfo from 'lib/app-info' -import {usePalette} from 'lib/hooks/usePalette' -import {useCustomPalette} from 'lib/hooks/useCustomPalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' -import {useAnalytics} from 'lib/analytics/analytics' -import {NavigationProp} from 'lib/routes/types' -import {HandIcon, HashtagIcon} from 'lib/icons' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import Clipboard from '@react-native-clipboard/clipboard' -import {makeProfileLink} from 'lib/routes/links' -import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' -import { - useSetMinimalShellMode, - useThemePrefs, - useSetThemePrefs, - useOnboardingDispatch, -} from '#/state/shell' +import {clearLegacyStorage} from '#/state/persisted/legacy' +// TODO import {useInviteCodesQuery} from '#/state/queries/invites' +import {clear as clearStorage} from '#/state/persisted/store' import { useRequireAltTextEnabled, useSetRequireAltTextEnabled, } from '#/state/preferences' -import {useSession, useSessionApi, SessionAccount} from '#/state/session' -import {useProfileQuery} from '#/state/queries/profile' -import {useClearPreferencesMutation} from '#/state/queries/preferences' -// TODO import {useInviteCodesQuery} from '#/state/queries/invites' -import {clear as clearStorage} from '#/state/persisted/store' -import {clearLegacyStorage} from '#/state/persisted/legacy' -import {STATUS_PAGE_URL} from 'lib/constants' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useQueryClient} from '@tanstack/react-query' -import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {useCloseAllActiveElements} from '#/state/util' import { useInAppBrowser, useSetInAppBrowser, } from '#/state/preferences/in-app-browser' -import {isNative} from '#/platform/detection' -import {useDialogControl} from '#/components/Dialog' - -import {s, colors} from 'lib/styles' -import {ScrollView} from 'view/com/util/Views' +import {useClearPreferencesMutation} from '#/state/queries/preferences' +import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useProfileQuery} from '#/state/queries/profile' +import {SessionAccount, useSession, useSessionApi} from '#/state/session' +import { + useOnboardingDispatch, + useSetMinimalShellMode, + useSetThemePrefs, + useThemePrefs, +} from '#/state/shell' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' +import {useAnalytics} from 'lib/analytics/analytics' +import * as AppInfo from 'lib/app-info' +import {STATUS_PAGE_URL} from 'lib/constants' +import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' +import {useCustomPalette} from 'lib/hooks/useCustomPalette' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {HandIcon, HashtagIcon} from 'lib/icons' +import {makeProfileLink} from 'lib/routes/links' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {NavigationProp} from 'lib/routes/types' +import {colors, s} from 'lib/styles' +import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' +import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' +import {ToggleButton} from 'view/com/util/forms/ToggleButton' import {Link, TextLink} from 'view/com/util/Link' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {UserAvatar} from 'view/com/util/UserAvatar' -import {ToggleButton} from 'view/com/util/forms/ToggleButton' -import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' -import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' -import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' -import {ExportCarDialog} from './ExportCarDialog' +import {ScrollView} from 'view/com/util/Views' +import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' +import {ExportCarDialog} from './ExportCarDialog' function SettingsAccountCard({account}: {account: SessionAccount}) { const pal = usePalette('default') @@ -890,9 +890,7 @@ export function SettingsScreen({}: Props) { accessibilityRole="button" onPress={onPressBuildInfo}> - - Build version {AppInfo.appVersion} {AppInfo.updateChannel} - + Version {AppInfo.appVersion}