Bluesky app fork with some witchin' additions 💫

Add OTA updates support for `testflight` channel (#3291)

* some progress

another adjustment, testing

another adjustment, testing

fix again

fix again

set default runtime version

fix

test this script

test this script

test this script

add build numbers to the deployment url

clean

give script access to build number

add `useBuildNumberEnv` without a bump

new line

fix missing async

add channel name to deployment url

add updates check on launch for testflight users

ver bump

init updates on launch for native

add `testflight` as default in build submit

add is_testflight check

* disable inline predictions to prevent ios composer jank

* temp bump

* Revert "temp bump"

This reverts commit 44c51134a35d817c73edb1e635495597c95117b3.

* adjustments

version bump

adjust

fixes

test

* cleanup and finalize

drop check down to every 15 minutes

adjustments

change to 15 mins

use jq to get version if necessary

rm test on push

figured it out

remove nightly testflight releases

test again again again again again AGAIN ONCE MORE

test again again again again again AGAIN

test again again again again again AGAIN

test again again again again again

test again again again again

test again again again

test again again

test again

test

test

test

run deploy if necessary

run deploy if necessary

run deploy if necessary

run deploy if necessary

run deploy if necessary

remove test message

fix environment

oops

cleanup

merge in changes

* remove unnecessary `workflow_call`

* remove changes that have been merged into main now

* finalize android

update git ignore

rm test stuff from the bundle action

remove test message

test message

fix

test message

test message

few android fixes

few android fixes

fix jq

add a test message

fix slack webhook

create android deployments test 2

create android deployments

add `testflight-android` profile to eas.json

more cleanup

some more cleanup

simplify some logic

remove unnecessary channel

rename to `useOTAUpdates`

* rm test portion

authored by hailey.at and committed by

GitHub 73df7e53 02b2ab4f

+583 -150
+1 -1
.github/workflows/build-submit-android.yml
··· 59 59 echo "$json" > google-services.json 60 60 61 61 - name: 🏗️ EAS Build 62 - run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive 62 + run: yarn use-build-number-with-bump eas build -p android --profile production --local --output build.aab --non-interactive 63 63 64 64 - name: 🚀 Deploy 65 65 run: eas submit -p android --non-interactive --path build.aab
+2 -3
.github/workflows/build-submit-ios.yml
··· 2 2 name: Build and Submit iOS 3 3 4 4 on: 5 - schedule: 6 - - cron: '0 5 * * *' 7 5 workflow_dispatch: 8 6 inputs: 9 7 profile: 10 8 type: choice 11 9 description: Build profile to use 12 10 options: 11 + - testflight 13 12 - production 14 13 15 14 jobs: ··· 69 68 echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json 70 69 71 70 - name: 🏗️ EAS Build 72 - run: yarn use-build-number eas build -p ios --profile production --local --output build.ipa --non-interactive 71 + run: yarn use-build-number-with-bump eas build -p ios --profile ${{ inputs.profile || 'testflight' }} --local --output build.ipa --non-interactive 73 72 74 73 - name: 🚀 Deploy 75 74 run: eas submit -p ios --non-interactive --path build.ipa
+221 -6
.github/workflows/bundle-deploy-eas-update.yml
··· 4 4 on: 5 5 workflow_dispatch: 6 6 inputs: 7 + channel: 8 + type: choice 9 + description: Deployment channel to use 10 + options: 11 + - testflight 12 + - production 7 13 runtimeVersion: 8 14 type: string 9 15 description: Runtime version (in x.x.x format) that this update is for ··· 13 19 bundleDeploy: 14 20 name: Bundle and Deploy EAS Update 15 21 runs-on: ubuntu-latest 22 + outputs: 23 + fingerprint-diff: ${{ steps.fingerprint.outputs.fingerprint-diff }} 16 24 steps: 25 + - name: Check for EXPO_TOKEN 26 + run: > 27 + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then 28 + 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" 29 + exit 1 30 + fi 31 + 32 + # Validate the version if one is supplied. This should generally happen if the update is for a production client 17 33 - name: 🧐 Validate version 34 + if: ${{ inputs.runtimeVersion }} 18 35 run: | 19 - [[ "${{ github.event.inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1 36 + if [ -z "${{ inputs.runtimeVersion }}" ]; then 37 + [[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1 38 + fi 20 39 21 40 - name: ⬇️ Checkout 22 41 uses: actions/checkout@v4 42 + with: 43 + fetch-depth: 100 44 + 45 + - name: ⬇️ Fetch commits from base branch 46 + run: git fetch origin main:main --depth 100 47 + 48 + # This should get the current production release's commit's hash to see if the update is compatible 49 + - name: 🕵️ Get the base commit 50 + id: base-commit 51 + run: | 52 + if [ -z "${{ inputs.channel == 'production' }}" ]; then 53 + echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT" 54 + else 55 + echo base-commit=$(git log -n 1 --skip 1 main --pretty=format:'%H') >> "$GITHUB_OUTPUT" 56 + fi 57 + 58 + - name: ✓ Make sure we found a base commit 59 + run: | 60 + if [ -z "${{ steps.base-commit.outputs.base-commit }}" ]; then 61 + echo "Could not find a base commit for this release. Exiting." 62 + exit 1 63 + fi 23 64 24 65 - name: 🔧 Setup Node 25 66 uses: actions/setup-node@v4 ··· 30 71 - name: ⚙️ Install Dependencies 31 72 run: yarn install 32 73 33 - - name: 🪛 Install jq 34 - uses: dcarbone/install-jq-action@v2 74 + # Run the fingerprint 75 + - name: 📷 Check fingerprint 76 + id: fingerprint 77 + uses: expo/expo-github-action/fingerprint@main 78 + with: 79 + previous-git-commit: ${{ steps.base-commit.outputs.base-commit }} 80 + 81 + - name: 👀 Debug fingerprint 82 + run: | 83 + echo "previousGitCommit=${{ steps.fingerprint.outputs.previous-git-commit }} currentGitCommit=${{ steps.fingerprint.outputs.current-git-commit }}" 84 + echo "isPreviousFingerprintEmpty=${{ steps.fingerprint.outputs.previous-fingerprint == '' }}" 85 + 86 + - name: 🔨 Setup EAS 87 + uses: expo/expo-github-action@v8 88 + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} 89 + with: 90 + expo-version: latest 91 + eas-version: latest 92 + token: ${{ secrets.EXPO_TOKEN }} 35 93 36 94 - name: ⛏️ Setup Expo 95 + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} 37 96 run: yarn global add eas-cli-local-build-plugin 38 97 98 + - name: 🪛 Setup jq 99 + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} 100 + uses: dcarbone/install-jq-action@v2 101 + 39 102 - name: 🔤 Compile Translations 103 + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} 40 104 run: yarn intl:build 41 105 42 106 - name: ✏️ Write environment variables 107 + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} 43 108 run: | 44 109 export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' 45 110 echo "${{ secrets.ENV_TOKEN }}" > .env 46 111 echo "$json" > google-services.json 47 112 48 113 - name: 🏗️ Create Bundle 49 - run: yarn export 114 + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} 115 + run: EXPO_PUBLIC_ENV="${{ inputs.channel || 'testflight' }}" yarn export 50 116 51 117 - name: 📦 Package Bundle and 🚀 Deploy 52 - run: yarn make-deploy-bundle 118 + if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }} 119 + run: yarn use-build-number bash scripts/bundleUpdate.sh 53 120 env: 54 121 DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }} 55 - RUNTIME_VERSION: ${{ github.event.inputs.runtimeVersion }} 122 + RUNTIME_VERSION: ${{ inputs.runtimeVersion }} 123 + CHANNEL_NAME: ${{ inputs.channel || 'testflight' }} 124 + 125 + # GitHub actions are horrible so let's just copy paste this in 126 + buildIfNecessaryIOS: 127 + name: Build and Submit iOS 128 + runs-on: macos-14 129 + needs: [bundleDeploy] 130 + # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be 131 + # available here 132 + if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-diff != '[]' }} 133 + steps: 134 + - name: Check for EXPO_TOKEN 135 + run: > 136 + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then 137 + 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" 138 + exit 1 139 + fi 140 + 141 + - name: ⬇️ Checkout 142 + uses: actions/checkout@v4 143 + 144 + - name: 🔧 Setup Node 145 + uses: actions/setup-node@v4 146 + with: 147 + node-version-file: .nvmrc 148 + cache: yarn 149 + 150 + - name: 🔨 Setup EAS 151 + uses: expo/expo-github-action@v8 152 + with: 153 + expo-version: latest 154 + eas-version: latest 155 + token: ${{ secrets.EXPO_TOKEN }} 156 + 157 + - name: ⛏️ Setup EAS local builds 158 + run: yarn global add eas-cli-local-build-plugin 159 + 160 + - name: ⚙️ Install dependencies 161 + run: yarn install 162 + 163 + - name: ☕️ Setup Cocoapods 164 + uses: maxim-lobanov/setup-cocoapods@v1 165 + with: 166 + version: 1.14.3 167 + 168 + - name: 💾 Cache Pods 169 + uses: actions/cache@v3 170 + id: pods-cache 171 + with: 172 + path: ./ios/Pods 173 + # We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not 174 + # change unless the yarn version changes as well. 175 + key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }} 176 + 177 + - name: 🔤 Compile translations 178 + run: yarn intl:build 179 + 180 + - name: ✏️ Write environment variables 181 + run: | 182 + echo "${{ secrets.ENV_TOKEN }}" > .env 183 + echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json 184 + 185 + - name: 🏗️ EAS Build 186 + run: yarn use-build-number-with-bump eas build -p ios --profile testflight --local --output build.ipa --non-interactive 187 + 188 + - name: 🚀 Deploy 189 + run: eas submit -p ios --non-interactive --path build.ipa 190 + 191 + buildIfNecessaryAndroid: 192 + name: Build and Submit Android 193 + runs-on: ubuntu-latest 194 + needs: [ bundleDeploy ] 195 + # Gotta check if its NOT '[]' because any md5 hash in the outputs is detected as a possible secret and won't be 196 + # available here 197 + if: ${{ inputs.channel != 'production' && needs.bundleDeploy.outputs.fingerprint-diff != '[]' }} 198 + 199 + steps: 200 + - name: Check for EXPO_TOKEN 201 + run: > 202 + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then 203 + 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" 204 + exit 1 205 + fi 206 + 207 + - name: ⬇️ Checkout 208 + uses: actions/checkout@v4 209 + 210 + - name: 🔧 Setup Node 211 + uses: actions/setup-node@v4 212 + with: 213 + node-version-file: .nvmrc 214 + cache: yarn 215 + 216 + - name: 🔨 Setup EAS 217 + uses: expo/expo-github-action@v8 218 + with: 219 + expo-version: latest 220 + eas-version: latest 221 + token: ${{ secrets.EXPO_TOKEN }} 222 + 223 + - name: ⛏️ Setup EAS local builds 224 + run: yarn global add eas-cli-local-build-plugin 225 + 226 + - uses: actions/setup-java@v4 227 + with: 228 + distribution: 'temurin' 229 + java-version: '17' 230 + 231 + - name: ⚙️ Install dependencies 232 + run: yarn install 233 + 234 + - name: 🔤 Compile translations 235 + run: yarn intl:build 236 + 237 + - name: ✏️ Write environment variables 238 + run: | 239 + export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}' 240 + echo "${{ secrets.ENV_TOKEN }}" > .env 241 + echo "$json" > google-services.json 242 + 243 + - name: 🏗️ EAS Build 244 + run: yarn use-build-number-with-bump eas build -p android --profile testflight-android --local --output build.apk --non-interactive 245 + 246 + - name: ⏰ Get a timestamp 247 + id: timestamp 248 + uses: nanzm/get-time-action@master 249 + with: 250 + format: 'MM-DD-HH-mm-ss' 251 + 252 + - name: 🚀 Upload Artifact 253 + id: upload-artifact 254 + uses: actions/upload-artifact@v4 255 + with: 256 + retention-days: 30 257 + compression-level: 0 258 + name: build-${{ steps.timestamp.outputs.time }}.apk 259 + path: build.apk 260 + 261 + - name: 🔔 Notify Slack 262 + uses: slackapi/slack-github-action@v1.25.0 263 + with: 264 + payload: | 265 + { 266 + "text": "Android build is ready for testing. Download the artifact here: ${{ steps.upload-artifact.outputs.artifact-url }}" 267 + } 268 + env: 269 + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }} 270 + SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
+5 -1
.gitignore
··· 18 18 *.moved-aside 19 19 DerivedData 20 20 *.hmap 21 - *.ipa 22 21 *.xcuserstate 23 22 24 23 # Android/IntelliJ ··· 110 109 # i18n 111 110 src/locale/locales/_build/ 112 111 src/locale/locales/**/*.js 112 + 113 + # local builds 114 + *.apk 115 + *.aab 116 + *.ipa
+16 -9
app.config.js
··· 41 41 : process.env.BSKY_IOS_BUILD_NUMBER 42 42 43 43 const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' 44 + const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' 45 + 46 + const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' 44 47 45 48 return { 46 49 expo: { ··· 122 125 favicon: './assets/favicon.png', 123 126 }, 124 127 updates: { 125 - enabled: true, 126 - fallbackToCacheTimeout: 1000, 127 - url: 'https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302', 128 + url: 'https://updates.bsky.app/manifest', 129 + // TODO Eventually we want to enable this for all environments, but for now it will only be used for 130 + // TestFlight builds 131 + enabled: IS_TESTFLIGHT, 132 + fallbackToCacheTimeout: 30000, 133 + codeSigningCertificate: './code-signing/certificate.pem', 134 + codeSigningMetadata: { 135 + keyid: 'main', 136 + alg: 'rsa-v1_5-sha256', 137 + }, 138 + checkAutomatically: 'NEVER', 139 + channel: UPDATES_CHANNEL, 128 140 }, 141 + assetBundlePatterns: ['**/*'], 129 142 plugins: [ 130 143 'expo-localization', 131 144 Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo', ··· 143 156 kotlinVersion: '1.8.0', 144 157 newArchEnabled: false, 145 158 }, 146 - }, 147 - ], 148 - [ 149 - 'expo-updates', 150 - { 151 - username: 'blueskysocial', 152 159 }, 153 160 ], 154 161 [
+30 -4
eas.json
··· 16 16 "ios": { 17 17 "simulator": true, 18 18 "resourceClass": "large" 19 + }, 20 + "env": { 21 + "EXPO_PUBLIC_ENV": "production" 19 22 } 20 23 }, 21 24 "preview": { 22 25 "extends": "base", 23 26 "distribution": "internal", 24 - "channel": "preview", 27 + "channel": "production", 25 28 "ios": { 26 29 "resourceClass": "large" 30 + }, 31 + "env": { 32 + "EXPO_PUBLIC_ENV": "production" 27 33 } 28 34 }, 29 35 "production": { ··· 35 41 "android": { 36 42 "autoIncrement": true 37 43 }, 38 - "channel": "production" 44 + "channel": "production", 45 + "env": { 46 + "EXPO_PUBLIC_ENV": "production" 47 + } 39 48 }, 40 - "github": { 49 + "testflight": { 41 50 "extends": "base", 42 51 "ios": { 43 52 "autoIncrement": true ··· 45 54 "android": { 46 55 "autoIncrement": true 47 56 }, 48 - "channel": "production" 57 + "channel": "testflight", 58 + "env": { 59 + "EXPO_PUBLIC_ENV": "testflight" 60 + } 61 + }, 62 + "testflight-android": { 63 + "extends": "base", 64 + "distribution": "internal", 65 + "ios": { 66 + "autoIncrement": true 67 + }, 68 + "android": { 69 + "autoIncrement": true 70 + }, 71 + "channel": "testflight", 72 + "env": { 73 + "EXPO_PUBLIC_ENV": "testflight" 74 + } 49 75 } 50 76 }, 51 77 "submit": {
+7 -7
package.json
··· 1 1 { 2 2 "name": "bsky.app", 3 - "version": "1.75.0", 3 + "version": "1.76.0", 4 4 "private": true, 5 5 "engines": { 6 6 "node": ">=18" ··· 14 14 "ios": "expo run:ios", 15 15 "web": "expo start --web", 16 16 "use-build-number": "./scripts/useBuildNumberEnv.sh", 17 + "use-build-number-with-bump": "./scripts/useBuildNumberEnvWithBump.sh", 17 18 "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/", 18 - "build-all": "yarn intl:build && yarn use-build-number eas build --platform all", 19 - "build-ios": "yarn use-build-number eas build -p ios", 20 - "build-android": "yarn use-build-number eas build -p android", 21 - "build": "yarn use-build-number eas build", 19 + "build-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all", 20 + "build-ios": "yarn use-build-number-with-bump eas build -p ios", 21 + "build-android": "yarn use-build-number-with-bump eas build -p android", 22 + "build": "yarn use-build-number-with-bump eas build", 22 23 "start": "expo start --dev-client", 23 24 "start:prod": "expo start --dev-client --no-dev --minify", 24 25 "clean-cache": "rm -rf node_modules/.cache/babel-loader/*", ··· 43 44 "intl:compile": "lingui compile", 44 45 "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android", 45 46 "update-extensions": "bash scripts/updateExtensions.sh", 46 - "export": "npx expo export", 47 - "make-deploy-bundle": "bash scripts/bundleUpdate.sh" 47 + "export": "npx expo export" 48 48 }, 49 49 "dependencies": { 50 50 "@atproto/api": "^0.12.2",
+26
patches/expo-updates+0.24.7.patch
··· 1 + diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift 2 + index 189a5f5..8d5b8e6 100644 3 + --- a/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift 4 + +++ b/node_modules/expo-updates/ios/EXUpdates/Update/NewUpdate.swift 5 + @@ -68,13 +68,20 @@ public final class NewUpdate: Update { 6 + processedAssets.append(asset) 7 + } 8 + 9 + + // Instead of relying on various hacks to get the correct format for the specific 10 + + // platform on the backend, we can just add this little patch.. 11 + + let dateFormatter = DateFormatter() 12 + + dateFormatter.locale = Locale(identifier: "en_US_POSIX") 13 + + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" 14 + + let date = dateFormatter.date(from:commitTime) ?? RCTConvert.nsDate(commitTime)! 15 + + 16 + return Update( 17 + manifest: manifest, 18 + config: config, 19 + database: database, 20 + updateId: uuid, 21 + scopeKey: config.scopeKey, 22 + - commitTime: RCTConvert.nsDate(commitTime), 23 + + commitTime: date, 24 + runtimeVersion: runtimeVersion, 25 + keep: true, 26 + status: UpdateStatus.StatusPending,
+7
patches/expo-updates+0.24.7.patch.md
··· 1 + # Expo-Updates Patch 2 + 3 + This is a small patch to convert timestamp formats that are returned from the backend. Instead of relying on the 4 + backend to return the correct format for a specific format (the format required on Android is not the same as on iOS) 5 + we can just add this conversion in. 6 + 7 + Don't remove unless we make changes on the backend to support both platforms.
+5 -2
scripts/bundleUpdate.sh
··· 9 9 echo "Creating tarball..." 10 10 node scripts/bundleUpdate.js 11 11 12 + if [ -z "$RUNTIME_VERSION" ]; then 13 + RUNTIME_VERSION=$(cat package.json | jq '.version' -r) 14 + fi 15 + 12 16 cd bundleTempDir || exit 13 - 14 17 BUNDLE_VERSION=$(date +%s) 15 - DEPLOYMENT_URL="https://updates.bsky.app/v1/upload?runtime-version=$RUNTIME_VERSION&bundle-version=$BUNDLE_VERSION" 18 + 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" 16 19 17 20 tar czvf bundle.tar.gz ./* 18 21
+2 -6
scripts/useBuildNumberEnv.sh
··· 1 1 #!/bin/bash 2 2 outputIos=$(eas build:version:get -p ios) 3 3 outputAndroid=$(eas build:version:get -p android) 4 - currentIosVersion=${outputIos#*buildNumber - } 5 - currentAndroidVersion=${outputAndroid#*versionCode - } 6 - 7 - BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1)) 8 - BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1)) 4 + BSKY_IOS_BUILD_NUMBER=${outputIos#*buildNumber - } 5 + BSKY_ANDROID_VERSION_CODE=${outputAndroid#*versionCode - } 9 6 10 7 bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*" 11 -
+11
scripts/useBuildNumberEnvWithBump.sh
··· 1 + #!/bin/bash 2 + outputIos=$(eas build:version:get -p ios) 3 + outputAndroid=$(eas build:version:get -p android) 4 + currentIosVersion=${outputIos#*buildNumber - } 5 + currentAndroidVersion=${outputAndroid#*versionCode - } 6 + 7 + BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1)) 8 + BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1)) 9 + 10 + bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*" 11 +
+2
src/App.native.tsx
··· 19 19 import * as persisted from '#/state/persisted' 20 20 import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' 21 21 import {useIntentHandler} from 'lib/hooks/useIntentHandler' 22 + import {useOTAUpdates} from 'lib/hooks/useOTAUpdates' 22 23 import * as notifications from 'lib/notifications/notifications' 23 24 import { 24 25 asyncStoragePersister, ··· 60 61 const theme = useColorModeTheme() 61 62 const {_} = useLingui() 62 63 useIntentHandler() 64 + useOTAUpdates() 63 65 64 66 // init 65 67 useEffect(() => {
+3 -5
src/components/ReportDialog/SelectLabelerView.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import {AppBskyLabelerDefs} from '@atproto/api' 3 4 import {msg, Trans} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 - import {AppBskyLabelerDefs} from '@atproto/api' 6 6 7 7 export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 8 8 import {getLabelingServiceTitle} from '#/lib/moderation' 9 - 10 - import {atoms as a, useTheme, useBreakpoints} from '#/alf' 11 - import {Text} from '#/components/Typography' 9 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 12 10 import {Button, useButtonContext} from '#/components/Button' 13 11 import {Divider} from '#/components/Divider' 14 12 import * as LabelingServiceCard from '#/components/LabelingServiceCard' 15 - 13 + import {Text} from '#/components/Typography' 16 14 import {ReportDialogProps} from './types' 17 15 18 16 export function SelectLabelerView({
+6 -7
src/components/ReportDialog/SelectReportOptionView.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import {AppBskyLabelerDefs} from '@atproto/api' 3 4 import {msg, Trans} from '@lingui/macro' 4 5 import {useLingui} from '@lingui/react' 5 - import {AppBskyLabelerDefs} from '@atproto/api' 6 6 7 - import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions' 7 + import {ReportOption, useReportOptions} from '#/lib/moderation/useReportOptions' 8 + import {Link} from '#/components/Link' 8 9 import {DMCA_LINK} from '#/components/ReportDialog/const' 9 - import {Link} from '#/components/Link' 10 10 export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 11 11 12 - import {atoms as a, useTheme, useBreakpoints} from '#/alf' 13 - import {Text} from '#/components/Typography' 12 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 14 13 import { 15 14 Button, 16 15 ButtonIcon, ··· 19 18 } from '#/components/Button' 20 19 import {Divider} from '#/components/Divider' 21 20 import { 22 - ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, 23 21 ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, 22 + ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, 24 23 } from '#/components/icons/Chevron' 25 24 import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' 26 - 25 + import {Text} from '#/components/Typography' 27 26 import {ReportDialogProps} from './types' 28 27 29 28 export function SelectReportOptionView({
+6 -7
src/components/moderation/LabelPreference.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' 4 - import {useLingui} from '@lingui/react' 5 4 import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 6 7 7 import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' 8 + import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' 9 + import {getLabelStrings} from '#/lib/moderation/useLabelInfo' 8 10 import { 9 11 usePreferencesQuery, 10 12 usePreferencesSetContentLabelMutation, 11 13 } from '#/state/queries/preferences' 12 - import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' 13 - import {getLabelStrings} from '#/lib/moderation/useLabelInfo' 14 - 15 - import {useTheme, atoms as a, useBreakpoints} from '#/alf' 14 + import {atoms as a, useBreakpoints, useTheme} from '#/alf' 15 + import * as ToggleButton from '#/components/forms/ToggleButton' 16 + import {InlineLink} from '#/components/Link' 16 17 import {Text} from '#/components/Typography' 17 - import {InlineLink} from '#/components/Link' 18 18 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo' 19 - import * as ToggleButton from '#/components/forms/ToggleButton' 20 19 21 20 export function Outer({children}: React.PropsWithChildren<{}>) { 22 21 return (
+7 -3
src/lib/app-info.ts
··· 1 1 import VersionNumber from 'react-native-version-number' 2 - import * as Updates from 'expo-updates' 3 - export const updateChannel = Updates.channel 4 2 5 - export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})` 3 + export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' 4 + export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight' 5 + 6 + const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production' 7 + export const appVersion = `${VersionNumber.appVersion} (${ 8 + VersionNumber.buildVersion 9 + }, ${IS_DEV ? 'development' : UPDATES_CHANNEL})`
+142
src/lib/hooks/useOTAUpdates.ts
··· 1 + import React from 'react' 2 + import {Alert, AppState, AppStateStatus} from 'react-native' 3 + import app from 'react-native-version-number' 4 + import { 5 + checkForUpdateAsync, 6 + fetchUpdateAsync, 7 + isEnabled, 8 + reloadAsync, 9 + setExtraParamAsync, 10 + useUpdates, 11 + } from 'expo-updates' 12 + 13 + import {logger} from '#/logger' 14 + import {IS_TESTFLIGHT} from 'lib/app-info' 15 + import {isIOS} from 'platform/detection' 16 + 17 + const MINIMUM_MINIMIZE_TIME = 15 * 60e3 18 + 19 + async function setExtraParams() { 20 + await setExtraParamAsync( 21 + isIOS ? 'ios-build-number' : 'android-build-number', 22 + // Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is. 23 + // This just ensures it gets passed as a string 24 + `${app.buildVersion}`, 25 + ) 26 + await setExtraParamAsync( 27 + 'channel', 28 + IS_TESTFLIGHT ? 'testflight' : 'production', 29 + ) 30 + } 31 + 32 + export function useOTAUpdates() { 33 + const appState = React.useRef<AppStateStatus>('active') 34 + const lastMinimize = React.useRef(0) 35 + const ranInitialCheck = React.useRef(false) 36 + const timeout = React.useRef<NodeJS.Timeout>() 37 + const {isUpdatePending} = useUpdates() 38 + 39 + const setCheckTimeout = React.useCallback(() => { 40 + timeout.current = setTimeout(async () => { 41 + try { 42 + await setExtraParams() 43 + 44 + logger.debug('Checking for update...') 45 + const res = await checkForUpdateAsync() 46 + 47 + if (res.isAvailable) { 48 + logger.debug('Attempting to fetch update...') 49 + await fetchUpdateAsync() 50 + } else { 51 + logger.debug('No update available.') 52 + } 53 + } catch (e) { 54 + logger.warn('OTA Update Error', {error: `${e}`}) 55 + } 56 + }, 10e3) 57 + }, []) 58 + 59 + const onIsTestFlight = React.useCallback(() => { 60 + setTimeout(async () => { 61 + try { 62 + await setExtraParams() 63 + 64 + const res = await checkForUpdateAsync() 65 + if (res.isAvailable) { 66 + await fetchUpdateAsync() 67 + 68 + Alert.alert( 69 + 'Update Available', 70 + 'A new version of the app is available. Relaunch now?', 71 + [ 72 + { 73 + text: 'No', 74 + style: 'cancel', 75 + }, 76 + { 77 + text: 'Relaunch', 78 + style: 'default', 79 + onPress: async () => { 80 + await reloadAsync() 81 + }, 82 + }, 83 + ], 84 + ) 85 + } 86 + } catch (e: any) { 87 + // No need to handle 88 + } 89 + }, 3e3) 90 + }, []) 91 + 92 + React.useEffect(() => { 93 + // For Testflight users, we can prompt the user to update immediately whenever there's an available update. This 94 + // is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update 95 + // immediately. 96 + if (IS_TESTFLIGHT) { 97 + onIsTestFlight() 98 + return 99 + } else if (!isEnabled || __DEV__ || ranInitialCheck.current) { 100 + // Development client shouldn't check for updates at all, so we skip that here. 101 + return 102 + } 103 + 104 + setCheckTimeout() 105 + ranInitialCheck.current = true 106 + }, [onIsTestFlight, setCheckTimeout]) 107 + 108 + // After the app has been minimized for 30 minutes, we want to either A. install an update if one has become available 109 + // or B check for an update again. 110 + React.useEffect(() => { 111 + if (!isEnabled) return 112 + 113 + const subscription = AppState.addEventListener( 114 + 'change', 115 + async nextAppState => { 116 + if ( 117 + appState.current.match(/inactive|background/) && 118 + nextAppState === 'active' 119 + ) { 120 + // If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since 121 + // chances are that there isn't anything important going on in the current session. 122 + if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) { 123 + if (isUpdatePending) { 124 + await reloadAsync() 125 + } else { 126 + setCheckTimeout() 127 + } 128 + } 129 + } else { 130 + lastMinimize.current = Date.now() 131 + } 132 + 133 + appState.current = nextAppState 134 + }, 135 + ) 136 + 137 + return () => { 138 + clearTimeout(timeout.current) 139 + subscription.remove() 140 + } 141 + }, [isUpdatePending, setCheckTimeout]) 142 + }
+29 -31
src/screens/Moderation/index.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {useFocusEffect} from '@react-navigation/native' 3 + import {useSafeAreaFrame} from 'react-native-safe-area-context' 4 4 import {ComAtprotoLabelDefs} from '@atproto/api' 5 - import {Trans, msg} from '@lingui/macro' 6 - import {useLingui} from '@lingui/react' 7 5 import {LABELS} from '@atproto/api' 8 - import {useSafeAreaFrame} from 'react-native-safe-area-context' 9 - 10 - import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types' 11 - import {CenteredView} from '#/view/com/util/Views' 12 - import {ViewHeader} from '#/view/com/util/ViewHeader' 13 - import {useAnalytics} from 'lib/analytics/analytics' 14 - import {useSetMinimalShellMode} from '#/state/shell' 15 - import {useSession} from '#/state/session' 16 - import { 17 - useProfileQuery, 18 - useProfileUpdateMutation, 19 - } from '#/state/queries/profile' 20 - import {ScrollView} from '#/view/com/util/Views' 6 + import {msg, Trans} from '@lingui/macro' 7 + import {useLingui} from '@lingui/react' 8 + import {useFocusEffect} from '@react-navigation/native' 21 9 10 + import {getLabelingServiceTitle} from '#/lib/moderation' 11 + import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 12 + import {logger} from '#/logger' 22 13 import { 23 - UsePreferencesQueryResponse, 24 14 useMyLabelersQuery, 25 15 usePreferencesQuery, 16 + UsePreferencesQueryResponse, 26 17 usePreferencesSetAdultContentMutation, 27 18 } from '#/state/queries/preferences' 28 - 29 - import {getLabelingServiceTitle} from '#/lib/moderation' 30 - import {logger} from '#/logger' 31 - import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf' 19 + import { 20 + useProfileQuery, 21 + useProfileUpdateMutation, 22 + } from '#/state/queries/profile' 23 + import {useSession} from '#/state/session' 24 + import {useSetMinimalShellMode} from '#/state/shell' 25 + import {useAnalytics} from 'lib/analytics/analytics' 26 + import {ViewHeader} from '#/view/com/util/ViewHeader' 27 + import {CenteredView} from '#/view/com/util/Views' 28 + import {ScrollView} from '#/view/com/util/Views' 29 + import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf' 30 + import {Button, ButtonText} from '#/components/Button' 31 + import * as Dialog from '#/components/Dialog' 32 + import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 33 + import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 32 34 import {Divider} from '#/components/Divider' 35 + import * as Toggle from '#/components/forms/Toggle' 36 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 33 37 import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 38 + import {Props as SVGIconProps} from '#/components/icons/common' 39 + import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 34 40 import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 35 41 import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 36 - import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 37 - import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 38 - import {Text} from '#/components/Typography' 39 - import * as Toggle from '#/components/forms/Toggle' 42 + import * as LabelingService from '#/components/LabelingServiceCard' 40 43 import {InlineLink, Link} from '#/components/Link' 41 - import {Button, ButtonText} from '#/components/Button' 42 44 import {Loader} from '#/components/Loader' 43 - import * as LabelingService from '#/components/LabelingServiceCard' 44 45 import {GlobalLabelPreference} from '#/components/moderation/LabelPreference' 45 - import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 46 - import {Props as SVGIconProps} from '#/components/icons/common' 47 - import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 48 - import * as Dialog from '#/components/Dialog' 46 + import {Text} from '#/components/Typography' 49 47 50 48 function ErrorState({error}: {error: string}) { 51 49 const t = useTheme()
+13 -14
src/screens/Profile/Sections/Labels.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 + import {useSafeAreaFrame} from 'react-native-safe-area-context' 3 4 import { 4 5 AppBskyLabelerDefs, 5 - ModerationOpts, 6 + InterpretedLabelValueDefinition, 6 7 interpretLabelValueDefinitions, 7 - InterpretedLabelValueDefinition, 8 + ModerationOpts, 8 9 } from '@atproto/api' 9 - import {Trans, msg} from '@lingui/macro' 10 + import {msg, Trans} from '@lingui/macro' 10 11 import {useLingui} from '@lingui/react' 11 - import {useSafeAreaFrame} from 'react-native-safe-area-context' 12 12 13 - import {useScrollHandlers} from '#/lib/ScrollContext' 14 13 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 15 14 import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' 15 + import {useScrollHandlers} from '#/lib/ScrollContext' 16 + import {isNative} from '#/platform/detection' 16 17 import {ListRef} from '#/view/com/util/List' 17 - import {SectionRef} from './types' 18 - import {isNative} from '#/platform/detection' 19 - 20 - import {useTheme, atoms as a} from '#/alf' 18 + import {CenteredView, ScrollView} from '#/view/com/util/Views' 19 + import {atoms as a, useTheme} from '#/alf' 20 + import {Divider} from '#/components/Divider' 21 + import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 22 + import {Loader} from '#/components/Loader' 23 + import {LabelerLabelPreference} from '#/components/moderation/LabelPreference' 21 24 import {Text} from '#/components/Typography' 22 - import {Loader} from '#/components/Loader' 23 - import {Divider} from '#/components/Divider' 24 - import {CenteredView, ScrollView} from '#/view/com/util/Views' 25 25 import {ErrorState} from '../ErrorState' 26 - import {LabelerLabelPreference} from '#/components/moderation/LabelPreference' 27 - import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 26 + import {SectionRef} from './types' 28 27 29 28 interface LabelsSectionProps { 30 29 isLabelerLoading: boolean
+42 -44
src/view/screens/Settings/index.tsx
··· 3 3 ActivityIndicator, 4 4 Linking, 5 5 Platform, 6 - StyleSheet, 7 6 Pressable, 7 + StyleSheet, 8 8 TextStyle, 9 9 TouchableOpacity, 10 10 View, 11 11 ViewStyle, 12 12 } from 'react-native' 13 - import {useFocusEffect, useNavigation} from '@react-navigation/native' 14 13 import { 15 14 FontAwesomeIcon, 16 15 FontAwesomeIconStyle, 17 16 } from '@fortawesome/react-native-fontawesome' 18 - import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' 19 - import * as AppInfo from 'lib/app-info' 20 - import {usePalette} from 'lib/hooks/usePalette' 21 - import {useCustomPalette} from 'lib/hooks/useCustomPalette' 22 - import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 23 - import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' 24 - import {useAnalytics} from 'lib/analytics/analytics' 25 - import {NavigationProp} from 'lib/routes/types' 26 - import {HandIcon, HashtagIcon} from 'lib/icons' 17 + import {msg, Trans} from '@lingui/macro' 18 + import {useLingui} from '@lingui/react' 27 19 import Clipboard from '@react-native-clipboard/clipboard' 28 - import {makeProfileLink} from 'lib/routes/links' 29 - import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' 20 + import {useFocusEffect, useNavigation} from '@react-navigation/native' 21 + import {useQueryClient} from '@tanstack/react-query' 22 + 23 + import {isNative} from '#/platform/detection' 30 24 import {useModalControls} from '#/state/modals' 31 - import { 32 - useSetMinimalShellMode, 33 - useThemePrefs, 34 - useSetThemePrefs, 35 - useOnboardingDispatch, 36 - } from '#/state/shell' 25 + import {clearLegacyStorage} from '#/state/persisted/legacy' 26 + // TODO import {useInviteCodesQuery} from '#/state/queries/invites' 27 + import {clear as clearStorage} from '#/state/persisted/store' 37 28 import { 38 29 useRequireAltTextEnabled, 39 30 useSetRequireAltTextEnabled, 40 31 } from '#/state/preferences' 41 - import {useSession, useSessionApi, SessionAccount} from '#/state/session' 42 - import {useProfileQuery} from '#/state/queries/profile' 43 - import {useClearPreferencesMutation} from '#/state/queries/preferences' 44 - // TODO import {useInviteCodesQuery} from '#/state/queries/invites' 45 - import {clear as clearStorage} from '#/state/persisted/store' 46 - import {clearLegacyStorage} from '#/state/persisted/legacy' 47 - import {STATUS_PAGE_URL} from 'lib/constants' 48 - import {Trans, msg} from '@lingui/macro' 49 - import {useLingui} from '@lingui/react' 50 - import {useQueryClient} from '@tanstack/react-query' 51 - import {useLoggedOutViewControls} from '#/state/shell/logged-out' 52 - import {useCloseAllActiveElements} from '#/state/util' 53 32 import { 54 33 useInAppBrowser, 55 34 useSetInAppBrowser, 56 35 } from '#/state/preferences/in-app-browser' 57 - import {isNative} from '#/platform/detection' 58 - import {useDialogControl} from '#/components/Dialog' 59 - 60 - import {s, colors} from 'lib/styles' 61 - import {ScrollView} from 'view/com/util/Views' 36 + import {useClearPreferencesMutation} from '#/state/queries/preferences' 37 + import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' 38 + import {useProfileQuery} from '#/state/queries/profile' 39 + import {SessionAccount, useSession, useSessionApi} from '#/state/session' 40 + import { 41 + useOnboardingDispatch, 42 + useSetMinimalShellMode, 43 + useSetThemePrefs, 44 + useThemePrefs, 45 + } from '#/state/shell' 46 + import {useLoggedOutViewControls} from '#/state/shell/logged-out' 47 + import {useCloseAllActiveElements} from '#/state/util' 48 + import {useAnalytics} from 'lib/analytics/analytics' 49 + import * as AppInfo from 'lib/app-info' 50 + import {STATUS_PAGE_URL} from 'lib/constants' 51 + import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' 52 + import {useCustomPalette} from 'lib/hooks/useCustomPalette' 53 + import {usePalette} from 'lib/hooks/usePalette' 54 + import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 55 + import {HandIcon, HashtagIcon} from 'lib/icons' 56 + import {makeProfileLink} from 'lib/routes/links' 57 + import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 58 + import {NavigationProp} from 'lib/routes/types' 59 + import {colors, s} from 'lib/styles' 60 + import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' 61 + import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' 62 + import {ToggleButton} from 'view/com/util/forms/ToggleButton' 62 63 import {Link, TextLink} from 'view/com/util/Link' 64 + import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' 63 65 import {Text} from 'view/com/util/text/Text' 64 66 import * as Toast from 'view/com/util/Toast' 65 67 import {UserAvatar} from 'view/com/util/UserAvatar' 66 - import {ToggleButton} from 'view/com/util/forms/ToggleButton' 67 - import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' 68 - import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' 69 - import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' 68 + import {ScrollView} from 'view/com/util/Views' 69 + import {useDialogControl} from '#/components/Dialog' 70 + import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 70 71 import {ExportCarDialog} from './ExportCarDialog' 71 - import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' 72 72 73 73 function SettingsAccountCard({account}: {account: SessionAccount}) { 74 74 const pal = usePalette('default') ··· 890 890 accessibilityRole="button" 891 891 onPress={onPressBuildInfo}> 892 892 <Text type="sm" style={[styles.buildInfo, pal.textLight]}> 893 - <Trans> 894 - Build version {AppInfo.appVersion} {AppInfo.updateChannel} 895 - </Trans> 893 + <Trans>Version {AppInfo.appVersion}</Trans> 896 894 </Text> 897 895 </TouchableOpacity> 898 896 <Text type="sm" style={[pal.textLight]}>