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
zio/stable
Hailey 2024-04-03 15:14:44 -07:00 committed by GitHub
parent 02b2ab4f1f
commit 73df7e53b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 589 additions and 156 deletions

View File

@ -59,7 +59,7 @@ jobs:
echo "$json" > google-services.json echo "$json" > google-services.json
- name: 🏗️ EAS Build - 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 - name: 🚀 Deploy
run: eas submit -p android --non-interactive --path build.aab run: eas submit -p android --non-interactive --path build.aab

View File

@ -2,14 +2,13 @@
name: Build and Submit iOS name: Build and Submit iOS
on: on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
profile: profile:
type: choice type: choice
description: Build profile to use description: Build profile to use
options: options:
- testflight
- production - production
jobs: jobs:
@ -69,7 +68,7 @@ jobs:
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build - 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 - name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa run: eas submit -p ios --non-interactive --path build.ipa

View File

@ -4,6 +4,12 @@ name: Bundle and Deploy EAS Update
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
channel:
type: choice
description: Deployment channel to use
options:
- testflight
- production
runtimeVersion: runtimeVersion:
type: string type: string
description: Runtime version (in x.x.x format) that this update is for description: Runtime version (in x.x.x format) that this update is for
@ -13,13 +19,48 @@ jobs:
bundleDeploy: bundleDeploy:
name: Bundle and Deploy EAS Update name: Bundle and Deploy EAS Update
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
fingerprint-diff: ${{ steps.fingerprint.outputs.fingerprint-diff }}
steps: 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 - name: 🧐 Validate version
if: ${{ inputs.runtimeVersion }}
run: | 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 - name: ⬇️ Checkout
uses: actions/checkout@v4 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 - name: 🔧 Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@ -30,13 +71,167 @@ jobs:
- name: ⚙️ Install Dependencies - name: ⚙️ Install Dependencies
run: yarn install run: yarn install
- name: 🪛 Install jq # Run the fingerprint
uses: dcarbone/install-jq-action@v2 - 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 - name: ⛏️ Setup Expo
if: ${{ steps.fingerprint.outputs.fingerprint-diff == '[]' }}
run: yarn global add eas-cli-local-build-plugin 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 - 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 run: yarn intl:build
- name: ✏️ Write environment variables - name: ✏️ Write environment variables
@ -45,11 +240,31 @@ jobs:
echo "${{ secrets.ENV_TOKEN }}" > .env echo "${{ secrets.ENV_TOKEN }}" > .env
echo "$json" > google-services.json echo "$json" > google-services.json
- name: 🏗️ Create Bundle - name: 🏗️ EAS Build
run: yarn export 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 - name: ⏰ Get a timestamp
run: yarn make-deploy-bundle 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: env:
DENIS_API_KEY: ${{ secrets.DENIS_API_KEY }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
RUNTIME_VERSION: ${{ github.event.inputs.runtimeVersion }} SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK

6
.gitignore vendored
View File

@ -18,7 +18,6 @@ xcuserdata
*.moved-aside *.moved-aside
DerivedData DerivedData
*.hmap *.hmap
*.ipa
*.xcuserstate *.xcuserstate
# Android/IntelliJ # Android/IntelliJ
@ -110,3 +109,8 @@ google-services.json
# i18n # i18n
src/locale/locales/_build/ src/locale/locales/_build/
src/locale/locales/**/*.js src/locale/locales/**/*.js
# local builds
*.apk
*.aab
*.ipa

View File

@ -41,6 +41,9 @@ module.exports = function (config) {
: process.env.BSKY_IOS_BUILD_NUMBER : process.env.BSKY_IOS_BUILD_NUMBER
const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development' 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 { return {
expo: { expo: {
@ -122,10 +125,20 @@ module.exports = function (config) {
favicon: './assets/favicon.png', favicon: './assets/favicon.png',
}, },
updates: { updates: {
enabled: true, url: 'https://updates.bsky.app/manifest',
fallbackToCacheTimeout: 1000, // TODO Eventually we want to enable this for all environments, but for now it will only be used for
url: 'https://u.expo.dev/55bd077a-d905-4184-9c7f-94789ba0f302', // 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: [ plugins: [
'expo-localization', 'expo-localization',
Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo', Boolean(process.env.SENTRY_AUTH_TOKEN) && 'sentry-expo',
@ -145,12 +158,6 @@ module.exports = function (config) {
}, },
}, },
], ],
[
'expo-updates',
{
username: 'blueskysocial',
},
],
[ [
'expo-notifications', 'expo-notifications',
{ {

View File

@ -16,14 +16,20 @@
"ios": { "ios": {
"simulator": true, "simulator": true,
"resourceClass": "large" "resourceClass": "large"
},
"env": {
"EXPO_PUBLIC_ENV": "production"
} }
}, },
"preview": { "preview": {
"extends": "base", "extends": "base",
"distribution": "internal", "distribution": "internal",
"channel": "preview", "channel": "production",
"ios": { "ios": {
"resourceClass": "large" "resourceClass": "large"
},
"env": {
"EXPO_PUBLIC_ENV": "production"
} }
}, },
"production": { "production": {
@ -35,9 +41,12 @@
"android": { "android": {
"autoIncrement": true "autoIncrement": true
}, },
"channel": "production" "channel": "production",
"env": {
"EXPO_PUBLIC_ENV": "production"
}
}, },
"github": { "testflight": {
"extends": "base", "extends": "base",
"ios": { "ios": {
"autoIncrement": true "autoIncrement": true
@ -45,7 +54,24 @@
"android": { "android": {
"autoIncrement": true "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": { "submit": {

View File

@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.75.0", "version": "1.76.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -14,11 +14,12 @@
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"use-build-number": "./scripts/useBuildNumberEnv.sh", "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-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-all": "yarn intl:build && yarn use-build-number-with-bump eas build --platform all",
"build-ios": "yarn use-build-number eas build -p ios", "build-ios": "yarn use-build-number-with-bump eas build -p ios",
"build-android": "yarn use-build-number eas build -p android", "build-android": "yarn use-build-number-with-bump eas build -p android",
"build": "yarn use-build-number eas build", "build": "yarn use-build-number-with-bump eas build",
"start": "expo start --dev-client", "start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify", "start:prod": "expo start --dev-client --no-dev --minify",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*", "clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
@ -43,8 +44,7 @@
"intl:compile": "lingui compile", "intl:compile": "lingui compile",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android", "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
"update-extensions": "bash scripts/updateExtensions.sh", "update-extensions": "bash scripts/updateExtensions.sh",
"export": "npx expo export", "export": "npx expo export"
"make-deploy-bundle": "bash scripts/bundleUpdate.sh"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.12.2", "@atproto/api": "^0.12.2",

View File

@ -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,

View File

@ -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.

View File

@ -9,10 +9,13 @@ rm -rf bundle.tar.gz
echo "Creating tarball..." echo "Creating tarball..."
node scripts/bundleUpdate.js 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) 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 ./* tar czvf bundle.tar.gz ./*

View File

@ -1,11 +1,7 @@
#!/bin/bash #!/bin/bash
outputIos=$(eas build:version:get -p ios) outputIos=$(eas build:version:get -p ios)
outputAndroid=$(eas build:version:get -p android) outputAndroid=$(eas build:version:get -p android)
currentIosVersion=${outputIos#*buildNumber - } BSKY_IOS_BUILD_NUMBER=${outputIos#*buildNumber - }
currentAndroidVersion=${outputAndroid#*versionCode - } BSKY_ANDROID_VERSION_CODE=${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 $*" bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*"

View File

@ -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 $*"

View File

@ -19,6 +19,7 @@ import {init as initPersistedState} from '#/state/persisted'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs' import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {useIntentHandler} from 'lib/hooks/useIntentHandler' import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import * as notifications from 'lib/notifications/notifications' import * as notifications from 'lib/notifications/notifications'
import { import {
asyncStoragePersister, asyncStoragePersister,
@ -60,6 +61,7 @@ function InnerApp() {
const theme = useColorModeTheme() const theme = useColorModeTheme()
const {_} = useLingui() const {_} = useLingui()
useIntentHandler() useIntentHandler()
useOTAUpdates()
// init // init
useEffect(() => { useEffect(() => {

View File

@ -1,18 +1,16 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {AppBskyLabelerDefs} from '@atproto/api'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog' export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {getLabelingServiceTitle} from '#/lib/moderation' import {getLabelingServiceTitle} from '#/lib/moderation'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {atoms as a, useTheme, useBreakpoints} from '#/alf'
import {Text} from '#/components/Typography'
import {Button, useButtonContext} from '#/components/Button' import {Button, useButtonContext} from '#/components/Button'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import * as LabelingServiceCard from '#/components/LabelingServiceCard' import * as LabelingServiceCard from '#/components/LabelingServiceCard'
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types' import {ReportDialogProps} from './types'
export function SelectLabelerView({ export function SelectLabelerView({

View File

@ -1,16 +1,15 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {AppBskyLabelerDefs} from '@atproto/api'
import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions' import {ReportOption, useReportOptions} from '#/lib/moderation/useReportOptions'
import {DMCA_LINK} from '#/components/ReportDialog/const'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {DMCA_LINK} from '#/components/ReportDialog/const'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog' export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {atoms as a, useTheme, useBreakpoints} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import { import {
Button, Button,
ButtonIcon, ButtonIcon,
@ -19,11 +18,11 @@ import {
} from '#/components/Button' } from '#/components/Button'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import { import {
ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft,
ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
} from '#/components/icons/Chevron' } from '#/components/icons/Chevron'
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
import {Text} from '#/components/Typography'
import {ReportDialogProps} from './types' import {ReportDialogProps} from './types'
export function SelectReportOptionView({ export function SelectReportOptionView({

View File

@ -1,22 +1,21 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api' import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
import { import {
usePreferencesQuery, usePreferencesQuery,
usePreferencesSetContentLabelMutation, usePreferencesSetContentLabelMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
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 * as ToggleButton from '#/components/forms/ToggleButton' 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<{}>) { export function Outer({children}: React.PropsWithChildren<{}>) {
return ( return (

View File

@ -1,5 +1,9 @@
import VersionNumber from 'react-native-version-number' 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})`

View File

@ -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<AppStateStatus>('active')
const lastMinimize = React.useRef(0)
const ranInitialCheck = React.useRef(false)
const timeout = React.useRef<NodeJS.Timeout>()
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])
}

View File

@ -1,51 +1,49 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' 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 {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 {getLabelingServiceTitle} from '#/lib/moderation'
import {CenteredView} from '#/view/com/util/Views' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {ViewHeader} from '#/view/com/util/ViewHeader' import {logger} from '#/logger'
import {useAnalytics} from 'lib/analytics/analytics' import {
import {useSetMinimalShellMode} from '#/state/shell' useMyLabelersQuery,
import {useSession} from '#/state/session' usePreferencesQuery,
UsePreferencesQueryResponse,
usePreferencesSetAdultContentMutation,
} from '#/state/queries/preferences'
import { import {
useProfileQuery, useProfileQuery,
useProfileUpdateMutation, useProfileUpdateMutation,
} from '#/state/queries/profile' } 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 {ScrollView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf'
import { import {Button, ButtonText} from '#/components/Button'
UsePreferencesQueryResponse, import * as Dialog from '#/components/Dialog'
useMyLabelersQuery, import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
usePreferencesQuery, import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
usePreferencesSetAdultContentMutation,
} from '#/state/queries/preferences'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {logger} from '#/logger'
import {useTheme, atoms as a, useBreakpoints, ViewStyleProp} from '#/alf'
import {Divider} from '#/components/Divider' 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 {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 {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 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 * as LabelingService from '#/components/LabelingServiceCard'
import {InlineLink, Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {GlobalLabelPreference} from '#/components/moderation/LabelPreference' import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {Text} from '#/components/Typography'
import {Props as SVGIconProps} from '#/components/icons/common'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import * as Dialog from '#/components/Dialog'
function ErrorState({error}: {error: string}) { function ErrorState({error}: {error: string}) {
const t = useTheme() const t = useTheme()

View File

@ -1,30 +1,29 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useSafeAreaFrame} from 'react-native-safe-area-context'
import { import {
AppBskyLabelerDefs, AppBskyLabelerDefs,
ModerationOpts,
interpretLabelValueDefinitions,
InterpretedLabelValueDefinition, InterpretedLabelValueDefinition,
interpretLabelValueDefinitions,
ModerationOpts,
} from '@atproto/api' } from '@atproto/api'
import {Trans, msg} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
import {ListRef} from '#/view/com/util/List' import {useScrollHandlers} from '#/lib/ScrollContext'
import {SectionRef} from './types'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {ListRef} from '#/view/com/util/List'
import {useTheme, atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {Loader} from '#/components/Loader'
import {Divider} from '#/components/Divider'
import {CenteredView, ScrollView} from '#/view/com/util/Views' import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {ErrorState} from '../ErrorState' import {atoms as a, useTheme} from '#/alf'
import {LabelerLabelPreference} from '#/components/moderation/LabelPreference' import {Divider} from '#/components/Divider'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 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 { interface LabelsSectionProps {
isLabelerLoading: boolean isLabelerLoading: boolean

View File

@ -3,72 +3,72 @@ import {
ActivityIndicator, ActivityIndicator,
Linking, Linking,
Platform, Platform,
StyleSheet,
Pressable, Pressable,
StyleSheet,
TextStyle, TextStyle,
TouchableOpacity, TouchableOpacity,
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {msg, Trans} from '@lingui/macro'
import * as AppInfo from 'lib/app-info' import {useLingui} from '@lingui/react'
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 Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {makeProfileLink} from 'lib/routes/links' import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' import {useQueryClient} from '@tanstack/react-query'
import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import { import {clearLegacyStorage} from '#/state/persisted/legacy'
useSetMinimalShellMode, // TODO import {useInviteCodesQuery} from '#/state/queries/invites'
useThemePrefs, import {clear as clearStorage} from '#/state/persisted/store'
useSetThemePrefs,
useOnboardingDispatch,
} from '#/state/shell'
import { import {
useRequireAltTextEnabled, useRequireAltTextEnabled,
useSetRequireAltTextEnabled, useSetRequireAltTextEnabled,
} from '#/state/preferences' } 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 { import {
useInAppBrowser, useInAppBrowser,
useSetInAppBrowser, useSetInAppBrowser,
} from '#/state/preferences/in-app-browser' } from '#/state/preferences/in-app-browser'
import {isNative} from '#/platform/detection' import {useClearPreferencesMutation} from '#/state/queries/preferences'
import {useDialogControl} from '#/components/Dialog' import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useProfileQuery} from '#/state/queries/profile'
import {s, colors} from 'lib/styles' import {SessionAccount, useSession, useSessionApi} from '#/state/session'
import {ScrollView} from 'view/com/util/Views' 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 {Link, TextLink} from 'view/com/util/Link'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import {ToggleButton} from 'view/com/util/forms/ToggleButton' import {ScrollView} from 'view/com/util/Views'
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' import {useDialogControl} from '#/components/Dialog'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
import {ExportCarDialog} from './ExportCarDialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {ExportCarDialog} from './ExportCarDialog'
function SettingsAccountCard({account}: {account: SessionAccount}) { function SettingsAccountCard({account}: {account: SessionAccount}) {
const pal = usePalette('default') const pal = usePalette('default')
@ -890,9 +890,7 @@ export function SettingsScreen({}: Props) {
accessibilityRole="button" accessibilityRole="button"
onPress={onPressBuildInfo}> onPress={onPressBuildInfo}>
<Text type="sm" style={[styles.buildInfo, pal.textLight]}> <Text type="sm" style={[styles.buildInfo, pal.textLight]}>
<Trans> <Trans>Version {AppInfo.appVersion}</Trans>
Build version {AppInfo.appVersion} {AppInfo.updateChannel}
</Trans>
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Text type="sm" style={[pal.textLight]}> <Text type="sm" style={[pal.textLight]}>