Merge remote-tracking branch 'origin/main' into samuel/composer-keeb-avoiding-view

* origin/main: (25 commits)
  Rename root file from `js` to `ts`, improve platform splitting (#11012)
  Route iOS notification settings intent into the app (#11003)
  Bump actions/checkout from 6.0.3 to 7.0.0 (#11022)
  Bump the actions group with 3 updates (#11021)
  Use latest fingerprint-native action (#11016)
  Avoid leaking previous messages via replies (#11017)
  Always set `allTime` to `true` for search v2 (#11033)
  Update search input with `useAutocomplete` (#11023)
  Remove `react-native-dotenv` (#10706)
  Cull unused files (#11029)
  Always scroll to bottom of chat after sending a message (#10913)
  Replace react-native-compressor video path with native expo-bluesky-video-compress module [APP-2428] (#10954)
  WSOD when trying to create account from starter pack on non-default provider (#10970)
  Use correct param name for setup-bundletool action (#11020)
  Release Prep 1.126.0 (#11015)
  Fix chat input positioning on mobile android (#11013)
  Disable selectable profile description on Android (#11002)
  Fix CI - grant workflows permission for internal sync push (#11010)
  Set apple team id via environment variable (#11008)
  Fix 149/150 user limit for starter packs (#11001)
  ...
This commit is contained in:
Eric Bailey
2026-06-30 14:27:39 -05:00
183 changed files with 52037 additions and 29818 deletions
@@ -22,7 +22,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
@@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
@@ -22,7 +22,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
@@ -22,7 +22,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
@@ -22,7 +22,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+3 -3
View File
@@ -49,7 +49,7 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 5 fetch-depth: 5
@@ -136,7 +136,7 @@ jobs:
- name: 🔧 Setup bundletool - name: 🔧 Setup bundletool
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1 uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
with: with:
bundletool-version: "1.17.2" version: "1.18.3"
- name: 🔑 Decode keystore - name: 🔑 Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
@@ -238,7 +238,7 @@ jobs:
- name: 📎 Attach APK to GitHub Release - name: 📎 Attach APK to GitHub Release
id: attach id: attach
if: ${{ steps.release-check.outputs.exists == 'true' }} if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
files: Bluesky-${{ needs.build.outputs.package-version }}.apk files: Bluesky-${{ needs.build.outputs.package-version }}.apk
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 5 fetch-depth: 5
@@ -52,7 +52,7 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -70,7 +70,7 @@ jobs:
- name: 📷 Check fingerprint and install dependencies - name: 📷 Check fingerprint and install dependencies
id: fingerprint id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0 uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.1.0
with: with:
profile: ${{ inputs.channel || 'testflight' }} profile: ${{ inputs.channel || 'testflight' }}
previous-commit-tag: ${{ inputs.runtimeVersion }} previous-commit-tag: ${{ inputs.runtimeVersion }}
@@ -174,7 +174,7 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 5 fetch-depth: 5
@@ -329,7 +329,7 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 5 fetch-depth: 5
@@ -396,7 +396,7 @@ jobs:
- name: 🔧 Setup bundletool - name: 🔧 Setup bundletool
uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1 uses: amyu/setup-bundletool@cc2e1857284660bd625e43f2c8a45626f034302f # v1.1
with: with:
bundletool-version: "1.17.2" version: "1.18.3"
- name: 🔑 Decode keystore - name: 🔑 Decode keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode > run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode >
+2 -2
View File
@@ -54,7 +54,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 1 fetch-depth: 1
@@ -65,7 +65,7 @@ jobs:
aws-region: us-east-2 aws-region: us-east-2
- name: Claude - name: Claude
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150 uses: anthropics/claude-code-action@30544b674398ee15c84819bd87caf8a87e8c7b55 # v1.0.154
with: with:
use_bedrock: 'true' use_bedrock: 'true'
additional_permissions: | additional_permissions: |
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 1 fetch-depth: 1
@@ -51,7 +51,7 @@ jobs:
aws-region: us-east-2 aws-region: us-east-2
- name: Claude review - name: Claude review
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150 uses: anthropics/claude-code-action@30544b674398ee15c84819bd87caf8a87e8c7b55 # v1.0.154
with: with:
use_bedrock: 'true' use_bedrock: 'true'
additional_permissions: | additional_permissions: |
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Git Checkout - name: Git Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go tooling - name: Set up Go tooling
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with: with:
@@ -36,7 +36,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Git Checkout - name: Git Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go tooling - name: Set up Go tooling
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with: with:
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
job: [lint, prettier, typecheck] job: [lint, prettier, typecheck]
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Verify Node version pins match package.json - name: Verify Node version pins match package.json
run: | run: |
set -euo pipefail set -euo pipefail
@@ -87,7 +87,7 @@ jobs:
shard: [1, 2, 3, 4] shard: [1, 2, 3, 4]
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install node - name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
notes: ${{ steps.notes.outputs.notes }} notes: ${{ steps.notes.outputs.notes }}
steps: steps:
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}} ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
+1 -1
View File
@@ -125,7 +125,7 @@ jobs:
fi fi
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
ref: ${{ steps.pr-info.outputs.head-sha }} ref: ${{ steps.pr-info.outputs.head-sha }}
+3 -3
View File
@@ -27,7 +27,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -116,7 +116,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: ⬇️ Checkout - name: ⬇️ Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 100 fetch-depth: 100
@@ -134,7 +134,7 @@ jobs:
- name: 📷 Check fingerprint and install dependencies - name: 📷 Check fingerprint and install dependencies
id: fingerprint id: fingerprint
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0 uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.1.0
with: with:
profile: pull-request profile: pull-request
+5 -2
View File
@@ -14,7 +14,7 @@ jobs:
if: github.repository == 'bluesky-social/social-app' if: github.repository == 'bluesky-social/social-app'
steps: steps:
- name: Checkout public repo - name: Checkout public repo
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
# Don't persist the checkout auth header; the push below authenticates # Don't persist the checkout auth header; the push below authenticates
@@ -28,8 +28,11 @@ jobs:
private-key: ${{ secrets.SYNC_INTERNAL_PK }} private-key: ${{ secrets.SYNC_INTERNAL_PK }}
repositories: social-app-internal repositories: social-app-internal
# Scope the token down from the app's full installation permissions; # Scope the token down from the app's full installation permissions;
# pushing is the only thing this token is used for # pushing is the only thing this token is used for. The workflows
# permission is required because the sync includes files under
# .github/workflows/, which GitHub refuses to push without it.
permission-contents: write permission-contents: write
permission-workflows: write
- name: Push to internal repo - name: Push to internal repo
env: env:
TOKEN: ${{ steps.app-token.outputs.token }} TOKEN: ${{ steps.app-token.outputs.token }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out PR HEAD - name: Check out PR HEAD
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
+2 -2
View File
@@ -21,12 +21,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
with: with:
# Annotate the PR directly instead of uploading SARIF to the # Annotate the PR directly instead of uploading SARIF to the
# security tab, and fail the check on any finding # security tab, and fail the check on any finding
+2 -1
View File
@@ -135,7 +135,8 @@ appId: xyz.blueskyweb.app
- tapOn: - tapOn:
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: "Search for posts, users[,]? or feeds" - tapOn:
id: "searchScreenInput"
- inputText: "bob" - inputText: "bob"
- tapOn: - tapOn:
id: "searchAutoCompleteResult-bob.test" id: "searchAutoCompleteResult-bob.test"
+2 -1
View File
@@ -15,7 +15,8 @@ appId: xyz.blueskyweb.app
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: - tapOn:
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: "Search for posts, users[,]? or feeds" - tapOn:
id: "searchScreenInput"
- inputText: "b" - inputText: "b"
- tapOn: - tapOn:
id: "searchAutoCompleteResult-bob.test" id: "searchAutoCompleteResult-bob.test"
+4 -2
View File
@@ -15,8 +15,10 @@ appId: xyz.blueskyweb.app
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- tapOn: - tapOn:
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
- assertVisible: "Search for posts, users[,]? or feeds" - assertVisible:
- tapOn: "Search for posts, users[,]? or feeds" id: "searchScreenInput"
- tapOn:
id: "searchScreenInput"
- inputText: "b" - inputText: "b"
- tapOn: - tapOn:
id: "searchAutoCompleteResult-bob.test" id: "searchAutoCompleteResult-bob.test"
+2
View File
@@ -59,6 +59,7 @@ module.exports = function (_config) {
ios: { ios: {
supportsTablet: false, supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app', bundleIdentifier: 'xyz.blueskyweb.app',
appleTeamId: process.env.EXPO_APPLE_TEAM_ID,
config: { config: {
usesNonExemptEncryption: false, usesNonExemptEncryption: false,
}, },
@@ -81,6 +82,7 @@ module.exports = function (_config) {
'an', 'an',
'ast', 'ast',
'ca', 'ca',
'cs',
'cy', 'cy',
'da', 'da',
'de', 'de',
-13
View File
@@ -18,19 +18,6 @@ module.exports = function (api) {
plugins: [ plugins: [
'@lingui/babel-plugin-lingui-macro', '@lingui/babel-plugin-lingui-macro',
['babel-plugin-react-compiler', {target: '19'}], ['babel-plugin-react-compiler', {target: '19'}],
[
'module:react-native-dotenv',
{
envName: 'APP_ENV',
moduleName: '@env',
path: '.env',
blocklist: null,
allowlist: null,
safe: false,
allowUndefined: true,
verbose: false,
},
],
[ [
'module-resolver', 'module-resolver',
{ {
+2 -2
View File
@@ -6,8 +6,8 @@
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts" "start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.20.0", "@atproto/api": "^0.20.22",
"@atproto/dev-env": "^0.5.0", "@atproto/dev-env": "^0.5.3",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
+73 -13
View File
@@ -208,10 +208,10 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: ^0.20.0 specifier: ^0.20.22
version: 0.20.5 version: 0.20.22
'@atproto/dev-env': '@atproto/dev-env':
specifier: ^0.5.0 specifier: ^0.5.3
version: 0.5.3 version: 0.5.3
typescript: typescript:
specifier: ^6.0.3 specifier: ^6.0.3
@@ -257,8 +257,8 @@ packages:
resolution: {integrity: sha512-nmjM83KucbRnz/CDSXQX6FLH/GP93XjcI1u5WFlomPscmeOAP1+9AOVsMQlKmh27I3hgaSOdCr03GJjAIpuPZQ==} resolution: {integrity: sha512-nmjM83KucbRnz/CDSXQX6FLH/GP93XjcI1u5WFlomPscmeOAP1+9AOVsMQlKmh27I3hgaSOdCr03GJjAIpuPZQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/api@0.20.5': '@atproto/api@0.20.22':
resolution: {integrity: sha512-VqkRYKR9vRRk36NhtytJz5TPhNtR1hczCcfM+bWoOK6MBvWUT8HwelufrycFAIbFIH7n4ws+5UbnLYpTIBQZ6Q==} resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/aws@0.3.0': '@atproto/aws@0.3.0':
@@ -277,6 +277,10 @@ packages:
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==} resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.2':
resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==}
engines: {node: '>=22'}
'@atproto/common@0.1.0': '@atproto/common@0.1.0':
resolution: {integrity: sha512-OB5tWE2R19jwiMIs2IjQieH5KTUuMb98XGCn9h3xuu6NanwjlmbCYMv08fMYwIp3UQ6jcq//84cDT3Bu6fJD+A==} resolution: {integrity: sha512-OB5tWE2R19jwiMIs2IjQieH5KTUuMb98XGCn9h3xuu6NanwjlmbCYMv08fMYwIp3UQ6jcq//84cDT3Bu6fJD+A==}
@@ -331,6 +335,10 @@ packages:
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==} resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lex-data@0.1.3':
resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==}
engines: {node: '>=22'}
'@atproto/lex-document@0.1.0': '@atproto/lex-document@0.1.0':
resolution: {integrity: sha512-I2q2iwK8RnSHkBeAj5xdJNYdHiUQqlkbbleBSJKJXlYOYy5y86dWRo9IfE0l9E3qZ3/zvy1ydlEZmZupEt0FGg==} resolution: {integrity: sha512-I2q2iwK8RnSHkBeAj5xdJNYdHiUQqlkbbleBSJKJXlYOYy5y86dWRo9IfE0l9E3qZ3/zvy1ydlEZmZupEt0FGg==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -343,6 +351,10 @@ packages:
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==} resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lex-json@0.1.2':
resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==}
engines: {node: '>=22'}
'@atproto/lex-resolver@0.1.0': '@atproto/lex-resolver@0.1.0':
resolution: {integrity: sha512-zliMiRW4ttSNFreKxyvSpaYQjeWrKpV/lutnXI9BNE8Ysgnw8Rltm0bR7kG/y0OlyClxhhU/vAG+OR8NpjhU1Q==} resolution: {integrity: sha512-zliMiRW4ttSNFreKxyvSpaYQjeWrKpV/lutnXI9BNE8Ysgnw8Rltm0bR7kG/y0OlyClxhhU/vAG+OR8NpjhU1Q==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -360,6 +372,10 @@ packages:
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==} resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lexicon@0.7.3':
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
engines: {node: '>=22'}
'@atproto/oauth-provider-api@0.6.0': '@atproto/oauth-provider-api@0.6.0':
resolution: {integrity: sha512-wFioPBgI71v4PuEwYmIYPp4GNOyaRw1UX8FJq0AiNuUGMbqCjdd7ImSsG4b43hsHr5wOsMirsWyccjUEdhb+yQ==} resolution: {integrity: sha512-wFioPBgI71v4PuEwYmIYPp4GNOyaRw1UX8FJq0AiNuUGMbqCjdd7ImSsG4b43hsHr5wOsMirsWyccjUEdhb+yQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -400,6 +416,10 @@ packages:
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==} resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/syntax@0.6.3':
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
engines: {node: '>=22'}
'@atproto/ws-client@0.1.0': '@atproto/ws-client@0.1.0':
resolution: {integrity: sha512-8qG+A+htxEHpDJRgtYNUExUOejDwU4nE2SEJCFLiTc1G+s0GbFaeg7Fk5drQA9tLMHQWoU9uqIr1YQpJyvJs8w==} resolution: {integrity: sha512-8qG+A+htxEHpDJRgtYNUExUOejDwU4nE2SEJCFLiTc1G+s0GbFaeg7Fk5drQA9tLMHQWoU9uqIr1YQpJyvJs8w==}
engines: {node: '>=22'} engines: {node: '>=22'}
@@ -412,6 +432,10 @@ packages:
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==} resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/xrpc@0.8.2':
resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==}
engines: {node: '>=22'}
'@aws-crypto/crc32@5.2.0': '@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -2110,12 +2134,12 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@atproto/api@0.20.5': '@atproto/api@0.20.22':
dependencies: dependencies:
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.2
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.3
'@atproto/syntax': 0.6.1 '@atproto/syntax': 0.6.3
'@atproto/xrpc': 0.8.0 '@atproto/xrpc': 0.8.2
await-lock: 3.0.0 await-lock: 3.0.0
multiformats: 13.4.2 multiformats: 13.4.2
tlds: 1.261.0 tlds: 1.261.0
@@ -2140,7 +2164,7 @@ snapshots:
dependencies: dependencies:
'@atproto-labs/fetch-node': 0.3.0 '@atproto-labs/fetch-node': 0.3.0
'@atproto-labs/xrpc-utils': 0.1.0 '@atproto-labs/xrpc-utils': 0.1.0
'@atproto/api': 0.20.5 '@atproto/api': 0.20.22
'@atproto/common': 0.6.1 '@atproto/common': 0.6.1
'@atproto/crypto': 0.5.0 '@atproto/crypto': 0.5.0
'@atproto/did': 0.4.0 '@atproto/did': 0.4.0
@@ -2210,6 +2234,13 @@ snapshots:
'@atproto/syntax': 0.6.1 '@atproto/syntax': 0.6.1
zod: 3.25.76 zod: 3.25.76
'@atproto/common-web@0.5.2':
dependencies:
'@atproto/lex-data': 0.1.3
'@atproto/lex-json': 0.1.2
'@atproto/syntax': 0.6.3
zod: 3.25.76
'@atproto/common@0.1.0': '@atproto/common@0.1.0':
dependencies: dependencies:
'@ipld/dag-cbor': 7.0.3 '@ipld/dag-cbor': 7.0.3
@@ -2248,7 +2279,7 @@ snapshots:
'@atproto/dev-env@0.5.3': '@atproto/dev-env@0.5.3':
dependencies: dependencies:
'@atproto/api': 0.20.5 '@atproto/api': 0.20.22
'@atproto/bsky': 0.0.234 '@atproto/bsky': 0.0.234
'@atproto/bsync': 0.0.27 '@atproto/bsync': 0.0.27
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.0
@@ -2323,6 +2354,13 @@ snapshots:
uint8arrays: 5.1.1 uint8arrays: 5.1.1
unicode-segmenter: 0.14.5 unicode-segmenter: 0.14.5
'@atproto/lex-data@0.1.3':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
'@atproto/lex-document@0.1.0': '@atproto/lex-document@0.1.0':
dependencies: dependencies:
'@atproto/lex-schema': 0.1.1 '@atproto/lex-schema': 0.1.1
@@ -2345,6 +2383,11 @@ snapshots:
'@atproto/lex-data': 0.1.1 '@atproto/lex-data': 0.1.1
tslib: 2.8.1 tslib: 2.8.1
'@atproto/lex-json@0.1.2':
dependencies:
'@atproto/lex-data': 0.1.3
tslib: 2.8.1
'@atproto/lex-resolver@0.1.0': '@atproto/lex-resolver@0.1.0':
dependencies: dependencies:
'@atproto-labs/did-resolver': 0.3.0 '@atproto-labs/did-resolver': 0.3.0
@@ -2382,6 +2425,13 @@ snapshots:
multiformats: 13.4.2 multiformats: 13.4.2
zod: 3.25.76 zod: 3.25.76
'@atproto/lexicon@0.7.3':
dependencies:
'@atproto/common-web': 0.5.2
'@atproto/syntax': 0.6.3
multiformats: 13.4.2
zod: 3.25.76
'@atproto/oauth-provider-api@0.6.0': '@atproto/oauth-provider-api@0.6.0':
dependencies: dependencies:
'@atproto/jwk': 0.7.0 '@atproto/jwk': 0.7.0
@@ -2436,7 +2486,7 @@ snapshots:
'@atproto/ozone@0.1.176': '@atproto/ozone@0.1.176':
dependencies: dependencies:
'@atproto/api': 0.20.5 '@atproto/api': 0.20.22
'@atproto/common': 0.6.1 '@atproto/common': 0.6.1
'@atproto/crypto': 0.5.0 '@atproto/crypto': 0.5.0
'@atproto/identity': 0.5.0 '@atproto/identity': 0.5.0
@@ -2551,6 +2601,11 @@ snapshots:
iso-datestring-validator: 2.2.2 iso-datestring-validator: 2.2.2
tslib: 2.8.1 tslib: 2.8.1
'@atproto/syntax@0.6.3':
dependencies:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/ws-client@0.1.0': '@atproto/ws-client@0.1.0':
dependencies: dependencies:
'@atproto/common': 0.6.1 '@atproto/common': 0.6.1
@@ -2586,6 +2641,11 @@ snapshots:
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.1
zod: 3.25.76 zod: 3.25.76
'@atproto/xrpc@0.8.2':
dependencies:
'@atproto/lexicon': 0.7.3
zod: 3.25.76
'@aws-crypto/crc32@5.2.0': '@aws-crypto/crc32@5.2.0':
dependencies: dependencies:
'@aws-crypto/util': 5.2.0 '@aws-crypto/util': 5.2.0
+9 -45
View File
@@ -162,11 +162,6 @@
"count": 1 "count": 1
} }
}, },
"src/components/Autocomplete/Autocomplete.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/components/Autocomplete/useAutocomplete/index.ts": { "src/components/Autocomplete/useAutocomplete/index.ts": {
"react-hooks/immutability": { "react-hooks/immutability": {
"count": 1 "count": 1
@@ -932,11 +927,6 @@
"count": 1 "count": 1
} }
}, },
"src/lib/hooks/useTabFocusEffect.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/lib/hooks/useToggleMutationQueue.ts": { "src/lib/hooks/useToggleMutationQueue.ts": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 2 "count": 2
@@ -1078,17 +1068,6 @@
"count": 1 "count": 1
} }
}, },
"src/screens/Bookmarks/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 3
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"src/screens/Deactivated.tsx": { "src/screens/Deactivated.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 1 "count": 1
@@ -1866,14 +1845,6 @@
"count": 2 "count": 2
} }
}, },
"src/state/queries/search-posts.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"src/state/queries/starter-packs.ts": { "src/state/queries/starter-packs.ts": {
"@typescript-eslint/require-await": { "@typescript-eslint/require-await": {
"count": 1 "count": 1
@@ -1969,6 +1940,15 @@
} }
}, },
"src/view/com/composer/Composer.tsx": { "src/view/com/composer/Composer.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-misused-promises": {
"count": 4
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
},
"react-hooks/immutability": { "react-hooks/immutability": {
"count": 2 "count": 2
}, },
@@ -2295,14 +2275,6 @@
"count": 3 "count": 3
} }
}, },
"src/view/com/util/ViewSelector.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 1
}
},
"src/view/com/util/Views.tsx": { "src/view/com/util/Views.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 1 "count": 1
@@ -2318,14 +2290,6 @@
"count": 1 "count": 1
} }
}, },
"src/view/screens/Debug.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 4
}
},
"src/view/screens/Feeds.tsx": { "src/view/screens/Feeds.tsx": {
"@typescript-eslint/no-floating-promises": { "@typescript-eslint/no-floating-promises": {
"count": 3 "count": 3
View File
View File
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
'an', 'an',
'ast', 'ast',
'ca', 'ca',
'cs',
'cy', 'cy',
'da', 'da',
'de', 'de',
+21 -3
View File
@@ -10,6 +10,7 @@ This module consolidates several native features into a single Expo module:
- **Referrer**: Tracking how users arrive at the app (web referrers, app referrers, Google Play install referrer) - **Referrer**: Tracking how users arrive at the app (web referrers, app referrers, Google Play install referrer)
- **SharedPrefs**: Shared preferences storage using native platform APIs (UserDefaults on iOS, SharedPreferences on Android) - **SharedPrefs**: Shared preferences storage using native platform APIs (UserDefaults on iOS, SharedPreferences on Android)
- **VisibilityView**: A native view component that tracks which view is currently visible on screen - **VisibilityView**: A native view component that tracks which view is currently visible on screen
- **NotificationSettings**: iOS handler that routes the system "notification settings" intent into the app
## Modules ## Modules
@@ -123,9 +124,24 @@ This is useful for features like video autoplay, where you want to know which vi
- Android: Full support using View position tracking - Android: Full support using View position tracking
- Web: Passthrough component (renders children without tracking) - Web: Passthrough component (renders children without tracking)
## Architecture ### NotificationSettings
### TypeScript Layer iOS only. Has no JavaScript surface - it is a pure native side effect registered
at app launch.
When push permissions are requested with `provideAppNotificationSettings: true`,
iOS adds an in-app notification settings button to the system Settings screen for
Bluesky. Tapping it launches the app and triggers
`userNotificationCenter(_:openSettingsFor:)`. expo-notifications owns the
`UNUserNotificationCenter` delegate and fans this callback out to registered
`NotificationDelegate`s. This module registers one and converts the callback into
a `bluesky://settings/notifications` deep link, which the app's existing linking
config routes to the notification settings screen.
**Platform Support:**
- iOS: Full support
- Android: Not applicable (Android opens the system notification settings directly)
- Web: Not applicable
The module uses platform-specific file extensions to provide appropriate implementations: The module uses platform-specific file extensions to provide appropriate implementations:
@@ -170,7 +186,9 @@ The module uses platform-specific file extensions to provide appropriate impleme
### Expo Module Config ### Expo Module Config
The module is registered in `expo-module.config.json` with all four sub-modules for both iOS and Android. The module is registered in `expo-module.config.json`. The PlatformInfo,
Referrer, SharedPrefs, and VisibilityView sub-modules are registered for both iOS
and Android; NotificationSettings is iOS only.
### iOS ### iOS
@@ -5,7 +5,8 @@
"ExpoBlueskySharedPrefsModule", "ExpoBlueskySharedPrefsModule",
"ExpoBlueskyReferrerModule", "ExpoBlueskyReferrerModule",
"ExpoBlueskyVisibilityViewModule", "ExpoBlueskyVisibilityViewModule",
"ExpoPlatformInfoModule" "ExpoPlatformInfoModule",
"ExpoBlueskyNotificationSettingsModule"
] ]
}, },
"android": { "android": {
@@ -10,6 +10,7 @@ Pod::Spec.new do |s|
s.static_framework = true s.static_framework = true
s.dependency 'ExpoModulesCore' s.dependency 'ExpoModulesCore'
s.dependency 'EXNotifications'
# Swift/Objective-C compatibility # Swift/Objective-C compatibility
s.pod_target_xcconfig = { s.pod_target_xcconfig = {
@@ -0,0 +1,40 @@
import EXNotifications
import ExpoModulesCore
import UIKit
import UserNotifications
/*
* When we request push notification permissions with
* `provideAppNotificationSettings: true`, iOS adds an in-app notification
* settings button to the system Settings screen for Bluesky (and may surface
* it elsewhere, e.g. from a delivered notification). Tapping it launches the
* app and calls `userNotificationCenter(_:openSettingsFor:)`.
*
* expo-notifications owns the `UNUserNotificationCenter` delegate via its
* `NotificationCenterManager`, which fans that callback out to any registered
* `NotificationDelegate` through `openSettings(_:)`. We register here and turn
* the callback into a `bluesky://settings/notifications` deep link so the app's
* existing linking config routes the user to the notification settings screen.
*/
public class ExpoBlueskyNotificationSettingsModule: Module, NotificationDelegate {
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyNotificationSettings")
OnCreate {
NotificationCenterManager.shared.addDelegate(self)
}
OnDestroy {
NotificationCenterManager.shared.removeDelegate(self)
}
}
public func openSettings(_ notification: UNNotification?) {
guard let url = URL(string: "bluesky://settings/notifications") else {
return
}
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
}
@@ -0,0 +1,34 @@
apply plugin: 'com.android.library'
group = 'expo.modules.blueskyvideocompress'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
buildscript {
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
}
android {
compileSdkVersion safeExtGet("compileSdkVersion", 34)
namespace "expo.modules.blueskyvideocompress"
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
versionCode 1
versionName "1.0.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
}
@@ -0,0 +1,55 @@
package expo.modules.blueskyvideocompress
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaFormat
import android.os.Build
object CodecSelector {
// Source: https://github.com/numandev1/react-native-compressor/blob/f949b0868055178e7c8753e05202f784b1bcd589/android/src/main/java/com/reactnativecompressor/Video/VideoCompressor/compressor/Compressor.kt#L500
private val AVC_DENYLIST = setOf(
"c2.qti.avc.encoder"
)
private val SOFTWARE_PREFIXES = listOf(
"OMX.google.",
"c2.android.",
"c2.google."
)
data class EncoderInfo(
val name: String,
val mime: String,
val isHardware: Boolean
)
fun findEncoder(mime: String, requireHardware: Boolean): EncoderInfo? {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val candidates = codecList.codecInfos
.filter { it.isEncoder }
.filter { it.supportedTypes.any { t -> t.equals(mime, ignoreCase = true) } }
.filter { !it.name.contains("secure", ignoreCase = true) }
.filter { !(mime == MediaFormat.MIMETYPE_VIDEO_AVC && AVC_DENYLIST.contains(it.name)) }
val hardware = candidates.filter { isHardware(it) }
val selected = if (requireHardware) {
hardware.firstOrNull()
} else {
hardware.firstOrNull() ?: candidates.firstOrNull()
}
selected ?: return null
return EncoderInfo(
name = selected.name,
mime = mime,
isHardware = isHardware(selected)
)
}
private fun isHardware(info: MediaCodecInfo): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
info.isHardwareAccelerated
} else {
SOFTWARE_PREFIXES.none { info.name.startsWith(it, ignoreCase = true) }
}
}
}
@@ -0,0 +1,62 @@
package expo.modules.blueskyvideocompress
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.util.concurrent.ConcurrentHashMap
class ExpoBlueskyVideoCompressModule : Module() {
private val activeCompressors = ConcurrentHashMap<Int, VideoCompressor>()
override fun definition() = ModuleDefinition {
Name("ExpoBlueskyVideoCompress")
Events("onProgress")
AsyncFunction("probe") { uri: String ->
val context = appContext.reactContext
?: throw Error("React context is null")
return@AsyncFunction VideoProber.probe(context, uri)
}
AsyncFunction("compress") { uri: String, options: Map<String, Any?> ->
val context = appContext.reactContext
?: throw Error("React context is null")
val targetBitrate = (options["targetBitrate"] as? Number)?.toInt() ?: 0
val maxSize = (options["maxSize"] as? Number)?.toInt() ?: 1920
val codecPref = (options["codec"] as? String) ?: "auto"
val frameRateCap = ((options["frameRateCap"] as? Number)?.toInt() ?: 30).coerceAtLeast(1)
val jobId = (options["jobId"] as? Number)?.toInt() ?: 0
val compressor = VideoCompressor(
context = context,
uriString = uri,
targetBitrate = targetBitrate,
maxSize = maxSize,
codecPref = codecPref,
frameRateCap = frameRateCap,
jobId = jobId,
onProgress = { id, progress ->
sendEvent("onProgress", mapOf(
"id" to id,
"progress" to progress
))
}
)
activeCompressors[jobId] = compressor
try {
val result = compressor.compress()
activeCompressors.remove(jobId)
return@AsyncFunction result
} catch (e: Exception) {
activeCompressors.remove(jobId)
throw e
}
}
Function("cancel") { jobId: Int ->
activeCompressors.remove(jobId)?.cancel()
}
}
}
@@ -0,0 +1,96 @@
package expo.modules.blueskyvideocompress
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.view.Surface
class InputSurface(private val surface: Surface) {
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
init {
eglSetup()
}
private fun eglSetup() {
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
if (eglDisplay === EGL14.EGL_NO_DISPLAY) {
throw RuntimeException("unable to get EGL14 display")
}
val version = IntArray(2)
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
throw RuntimeException("unable to initialize EGL14")
}
val attribList = intArrayOf(
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
EGL14.EGL_NONE
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, 1, numConfigs, 0)
checkEglError("eglChooseConfig")
val contextAttribs = intArrayOf(
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
EGL14.EGL_NONE
)
eglContext = EGL14.eglCreateContext(
eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0
)
checkEglError("eglCreateContext")
val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
eglSurface = EGL14.eglCreateWindowSurface(
eglDisplay, configs[0], surface, surfaceAttribs, 0
)
checkEglError("eglCreateWindowSurface")
}
fun makeCurrent() {
EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)
checkEglError("eglMakeCurrent")
}
fun swapBuffers(): Boolean {
return EGL14.eglSwapBuffers(eglDisplay, eglSurface)
}
fun setPresentationTime(nsecs: Long) {
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs)
}
fun release() {
if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(
eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT
)
EGL14.eglDestroySurface(eglDisplay, eglSurface)
EGL14.eglDestroyContext(eglDisplay, eglContext)
EGL14.eglReleaseThread()
EGL14.eglTerminate(eglDisplay)
}
surface.release()
eglDisplay = EGL14.EGL_NO_DISPLAY
eglContext = EGL14.EGL_NO_CONTEXT
eglSurface = EGL14.EGL_NO_SURFACE
}
private fun checkEglError(msg: String) {
val error = EGL14.eglGetError()
if (error != EGL14.EGL_SUCCESS) {
throw RuntimeException("$msg: EGL error: 0x${Integer.toHexString(error)}")
}
}
}
@@ -0,0 +1,61 @@
package expo.modules.blueskyvideocompress
import android.graphics.SurfaceTexture
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
class OutputSurface : SurfaceTexture.OnFrameAvailableListener {
private val renderer = TextureRenderer()
private var surfaceTexture: SurfaceTexture? = null
private val stMatrix = FloatArray(16)
private val callbackThread = HandlerThread("OutputSurfaceCallbacks")
val surface: Surface
@Volatile
private var frameAvailable = false
private val frameSyncObject = Object()
init {
renderer.surfaceCreated()
callbackThread.start()
val handler = Handler(callbackThread.looper)
surfaceTexture = SurfaceTexture(renderer.getTextureId()).also {
it.setOnFrameAvailableListener(this, handler)
}
surface = Surface(surfaceTexture)
}
fun release() {
surface.release()
surfaceTexture?.release()
surfaceTexture = null
callbackThread.quitSafely()
}
fun awaitNewImage() {
val timeoutMs = 2500L
synchronized(frameSyncObject) {
while (!frameAvailable) {
frameSyncObject.wait(timeoutMs)
if (!frameAvailable) {
throw RuntimeException("Surface frame wait timed out")
}
}
frameAvailable = false
}
surfaceTexture!!.updateTexImage()
}
fun drawImage() {
surfaceTexture!!.getTransformMatrix(stMatrix)
renderer.drawFrame(stMatrix)
}
override fun onFrameAvailable(st: SurfaceTexture) {
synchronized(frameSyncObject) {
frameAvailable = true
frameSyncObject.notifyAll()
}
}
}
@@ -0,0 +1,156 @@
package expo.modules.blueskyvideocompress
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.Matrix
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
class TextureRenderer {
companion object {
private const val FLOAT_SIZE_BYTES = 4
private const val STRIDE_BYTES = 5 * FLOAT_SIZE_BYTES
private const val POS_OFFSET = 0
private const val UV_OFFSET = 3
private val VERTICES = floatArrayOf(
-1.0f, -1.0f, 0f, 0f, 0f,
1.0f, -1.0f, 0f, 1f, 0f,
-1.0f, 1.0f, 0f, 0f, 1f,
1.0f, 1.0f, 0f, 1f, 1f,
)
private const val VERTEX_SHADER = """
uniform mat4 uMVPMatrix;
uniform mat4 uSTMatrix;
attribute vec4 aPosition;
attribute vec4 aTextureCoord;
varying vec2 vTextureCoord;
void main() {
gl_Position = uMVPMatrix * aPosition;
vTextureCoord = (uSTMatrix * aTextureCoord).xy;
}
"""
private const val FRAGMENT_SHADER = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTextureCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vTextureCoord);
}
"""
}
private val vertices: FloatBuffer =
ByteBuffer.allocateDirect(VERTICES.size * FLOAT_SIZE_BYTES)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply { put(VERTICES); position(0) }
private val mvpMatrix = FloatArray(16)
private var program = 0
private var textureId = -1
private var uMVPMatrixHandle = 0
private var uSTMatrixHandle = 0
private var aPositionHandle = 0
private var aTextureCoordHandle = 0
init {
Matrix.setIdentityM(mvpMatrix, 0)
}
fun getTextureId(): Int = textureId
fun surfaceCreated() {
program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER)
aPositionHandle = GLES20.glGetAttribLocation(program, "aPosition")
aTextureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord")
uMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix")
uSTMatrixHandle = GLES20.glGetUniformLocation(program, "uSTMatrix")
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
textureId = textures[0]
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
GLES20.glTexParameterf(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR.toFloat()
)
GLES20.glTexParameterf(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR.toFloat()
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE
)
GLES20.glTexParameteri(
GLES11Ext.GL_TEXTURE_EXTERNAL_OES,
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE
)
}
fun drawFrame(stMatrix: FloatArray) {
GLES20.glClearColor(0f, 0f, 0f, 1f)
GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT or GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glUseProgram(program)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
vertices.position(POS_OFFSET)
GLES20.glVertexAttribPointer(
aPositionHandle, 3, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
)
GLES20.glEnableVertexAttribArray(aPositionHandle)
vertices.position(UV_OFFSET)
GLES20.glVertexAttribPointer(
aTextureCoordHandle, 2, GLES20.GL_FLOAT, false, STRIDE_BYTES, vertices
)
GLES20.glEnableVertexAttribArray(aTextureCoordHandle)
GLES20.glUniformMatrix4fv(uMVPMatrixHandle, 1, false, mvpMatrix, 0)
GLES20.glUniformMatrix4fv(uSTMatrixHandle, 1, false, stMatrix, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(aPositionHandle)
GLES20.glDisableVertexAttribArray(aTextureCoordHandle)
}
private fun createProgram(vertexSource: String, fragmentSource: String): Int {
val vertexShader = loadShader(GLES20.GL_VERTEX_SHADER, vertexSource)
val fragmentShader = loadShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
val program = GLES20.glCreateProgram()
GLES20.glAttachShader(program, vertexShader)
GLES20.glAttachShader(program, fragmentShader)
GLES20.glLinkProgram(program)
val linkStatus = IntArray(1)
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] != GLES20.GL_TRUE) {
val log = GLES20.glGetProgramInfoLog(program)
GLES20.glDeleteProgram(program)
throw RuntimeException("Could not link program: $log")
}
return program
}
private fun loadShader(type: Int, source: String): Int {
val shader = GLES20.glCreateShader(type)
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
val compiled = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
if (compiled[0] == 0) {
val log = GLES20.glGetShaderInfoLog(shader)
GLES20.glDeleteShader(shader)
throw RuntimeException("Could not compile shader $type: $log")
}
return shader
}
}
@@ -0,0 +1,582 @@
package expo.modules.blueskyvideocompress
import android.content.Context
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMuxer
import android.net.Uri
import android.os.Build
import android.util.Log
import java.io.File
import java.nio.ByteBuffer
import java.util.UUID
class VideoCompressor(
private val context: Context,
private val uriString: String,
private val targetBitrate: Int,
private val maxSize: Int,
private val codecPref: String,
private val frameRateCap: Int,
private val jobId: Int,
private val onProgress: (Int, Double) -> Unit
) {
companion object {
private const val TAG = "BskyVideoCompress"
private const val TIMEOUT_DEQUEUE = 100L
private const val I_FRAME_INTERVAL = 3
}
@Volatile
private var isCancelled = false
fun cancel() {
isCancelled = true
}
fun compress(): Map<String, Any> {
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
val tryHevc = codecPref == "hevc"
if (tryHevc) {
try {
return doCompress(useHevc = true, allowSoftwareFallback = false)
} catch (e: Exception) {
if (codecPref == "hevc" || isCancelled) throw e
Log.w(TAG, "HEVC encode failed, falling back to h264", e)
}
}
return doCompress(useHevc = false, allowSoftwareFallback = true)
}
private fun doCompress(useHevc: Boolean, allowSoftwareFallback: Boolean): Map<String, Any> {
val mime = if (useHevc) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC
val encoderInfo = CodecSelector.findEncoder(mime, requireHardware = !allowSoftwareFallback)
?: throw RuntimeException("No encoder for $mime")
try {
return runPipeline(encoderInfo, useHevc)
} catch (e: Exception) {
if (!allowSoftwareFallback || isCancelled || !encoderInfo.isHardware) throw e
Log.w(TAG, "Hardware encoder ${encoderInfo.name} failed, trying software", e)
val sw = CodecSelector.findEncoder(mime, requireHardware = false)
?.takeIf { !it.isHardware }
?: throw e
return runPipeline(sw, useHevc)
}
}
private fun runPipeline(
encoderInfo: CodecSelector.EncoderInfo,
useHevc: Boolean
): Map<String, Any> {
val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4")
var extractor: MediaExtractor? = null
var muxer: MediaMuxer? = null
var encoder: MediaCodec? = null
var decoder: MediaCodec? = null
var inputSurface: InputSurface? = null
var outputSurface: OutputSurface? = null
var muxerStarted = false
var outputDims = Pair(0, 0)
var durationUs = 0L
try {
val uri = Uri.parse(uriString)
extractor = MediaExtractor()
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
extractor.setDataSource(context, uri, null)
} else {
extractor.setDataSource(uriString)
}
var videoTrackIndex = -1
var audioTrackIndex = -1
var videoFormat: MediaFormat? = null
var audioFormat: MediaFormat? = null
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val trackMime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (trackMime.startsWith("video/") && videoTrackIndex == -1) {
videoTrackIndex = i
videoFormat = format
} else if (trackMime.startsWith("audio/") && audioTrackIndex == -1) {
audioTrackIndex = i
audioFormat = format
}
}
if (videoTrackIndex == -1 || videoFormat == null) {
throw RuntimeException("No video track found")
}
val sourceWidth = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
val sourceHeight = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
val rotation = if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) {
videoFormat.getInteger(MediaFormat.KEY_ROTATION)
} else 0
durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION)) {
videoFormat.getLong(MediaFormat.KEY_DURATION)
} else 0L
val sourceFps = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)
} else 30
outputDims = calculateOutputDims(sourceWidth, sourceHeight, rotation, maxSize)
val shouldPassthroughAudio = audioFormat != null && canPassthroughAudio(audioFormat)
val transcodedAudio: TranscodedAudio? = if (
audioTrackIndex >= 0 && audioFormat != null && !shouldPassthroughAudio
) {
transcodeAudioToAAC(audioTrackIndex, audioFormat)
} else null
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
val effectiveBitrate = if (targetBitrate > 0) {
targetBitrate
} else if (useHevc) 2_500_000 else 3_000_000
val encoderFormat = MediaFormat.createVideoFormat(
encoderInfo.mime, outputDims.first, outputDims.second
).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate)
setInteger(
MediaFormat.KEY_BITRATE_MODE,
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR
)
setInteger(MediaFormat.KEY_FRAME_RATE, frameRateCap)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709)
setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO)
setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setInteger(MediaFormat.KEY_PRIORITY, 0)
setInteger(MediaFormat.KEY_OPERATING_RATE, frameRateCap)
if (useHevc) {
setInteger(
MediaFormat.KEY_PROFILE,
MediaCodecInfo.CodecProfileLevel.HEVCProfileMain
)
} else {
setInteger(
MediaFormat.KEY_PROFILE,
MediaCodecInfo.CodecProfileLevel.AVCProfileHigh
)
setInteger(
MediaFormat.KEY_LEVEL,
MediaCodecInfo.CodecProfileLevel.AVCLevel41
)
}
}
}
encoder = MediaCodec.createByCodecName(encoderInfo.name)
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
val encoderInputSurface = encoder.createInputSurface()
inputSurface = InputSurface(encoderInputSurface)
inputSurface.makeCurrent()
outputSurface = OutputSurface()
encoder.start()
decoder = MediaCodec.createDecoderByType(
videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
)
// Ask the decoder to tone-map HDR (HLG/PQ) sources to SDR. Vendors may
// ignore the hint, but where supported it produces correct BT.709 pixels
// for the encoder rather than HDR pixels mislabeled as SDR.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
videoFormat.setInteger(
MediaFormat.KEY_COLOR_TRANSFER_REQUEST,
MediaFormat.COLOR_TRANSFER_SDR_VIDEO
)
}
decoder.configure(videoFormat, outputSurface.surface, null, 0)
decoder.start()
extractor.selectTrack(videoTrackIndex)
val frameDropEnabled = sourceFps > frameRateCap
val targetFrameIntervalUs = if (frameDropEnabled) 1_000_000L / frameRateCap else 0L
var nextTargetPtsUs = 0L
var muxerVideoTrack = -1
var muxerAudioTrack = -1
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
var decoderDone = false
var outputDone = false
var lastProgressMs = 0L
while (!outputDone && !isCancelled) {
if (!inputDone) {
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
if (idx >= 0) {
val buf = decoder.getInputBuffer(idx)
if (buf != null) {
val sz = extractor.readSampleData(buf, 0)
if (sz < 0) {
decoder.queueInputBuffer(
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputDone = true
} else {
decoder.queueInputBuffer(idx, 0, sz, extractor.sampleTime, 0)
extractor.advance()
}
}
}
}
if (!decoderDone) {
val status = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE)
if (status >= 0) {
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
val shouldRender = if (isEos) {
false
} else if (frameDropEnabled) {
if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) {
nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs
true
} else false
} else true
decoder.releaseOutputBuffer(status, shouldRender)
if (shouldRender) {
outputSurface.awaitNewImage()
outputSurface.drawImage()
inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
inputSurface.swapBuffers()
}
if (isEos) {
encoder.signalEndOfInputStream()
decoderDone = true
}
}
}
var encoderDrained = false
while (!outputDone && !isCancelled && !encoderDrained) {
val encIdx = encoder.dequeueOutputBuffer(bufferInfo, 0)
when {
encIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderDrained = true
encIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
if (!muxerStarted) {
muxerVideoTrack = muxer.addTrack(encoder.outputFormat)
if (audioTrackIndex >= 0 && audioFormat != null) {
if (shouldPassthroughAudio) {
muxerAudioTrack = muxer.addTrack(audioFormat)
} else if (transcodedAudio != null) {
muxerAudioTrack = muxer.addTrack(transcodedAudio.outputFormat)
}
}
muxer.start()
muxerStarted = true
}
}
encIdx >= 0 -> {
val data = encoder.getOutputBuffer(encIdx)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
bufferInfo.size = 0
}
if (data != null && bufferInfo.size > 0 && muxerStarted) {
muxer.writeSampleData(muxerVideoTrack, data, bufferInfo)
}
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
encoder.releaseOutputBuffer(encIdx, false)
if (isEos) {
outputDone = true
} else if (durationUs > 0) {
val now = System.currentTimeMillis()
if (now - lastProgressMs >= 100) {
lastProgressMs = now
val p = (bufferInfo.presentationTimeUs.toDouble() / durationUs)
.coerceIn(0.0, 1.0)
onProgress(jobId, p)
}
}
}
}
}
}
if (audioTrackIndex >= 0 && muxerAudioTrack >= 0 && muxerStarted && !isCancelled) {
if (shouldPassthroughAudio) {
passthroughAudio(audioTrackIndex, muxer, muxerAudioTrack)
} else if (transcodedAudio != null) {
writeTranscodedAudio(transcodedAudio.samples, muxer, muxerAudioTrack)
}
}
} finally {
try { decoder?.stop() } catch (_: Exception) {}
try { decoder?.release() } catch (_: Exception) {}
try { encoder?.stop() } catch (_: Exception) {}
try { encoder?.release() } catch (_: Exception) {}
try { outputSurface?.release() } catch (_: Exception) {}
try { inputSurface?.release() } catch (_: Exception) {}
try { extractor?.release() } catch (_: Exception) {}
try {
if (muxerStarted) muxer?.stop()
muxer?.release()
} catch (_: Exception) {}
}
if (isCancelled) {
outputFile.delete()
throw RuntimeException("Compression cancelled")
}
onProgress(jobId, 1.0)
val durationSeconds = durationUs / 1_000_000.0
return mapOf(
"uri" to "file://${outputFile.absolutePath}",
"size" to outputFile.length(),
"mimeType" to "video/mp4",
"width" to outputDims.first,
"height" to outputDims.second,
"duration" to durationSeconds,
"codec" to if (useHevc) "hevc" else "h264"
)
}
private fun passthroughAudio(audioTrackIndex: Int, muxer: MediaMuxer, muxerAudioTrack: Int) {
val audioExtractor = MediaExtractor()
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
} else {
audioExtractor.setDataSource(uriString)
}
audioExtractor.selectTrack(audioTrackIndex)
audioExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
val buffer = ByteBuffer.allocate(256 * 1024)
val info = MediaCodec.BufferInfo()
try {
while (!isCancelled) {
val sz = audioExtractor.readSampleData(buffer, 0)
if (sz < 0) break
info.offset = 0
info.size = sz
info.presentationTimeUs = audioExtractor.sampleTime
info.flags = audioExtractor.sampleFlags
muxer.writeSampleData(muxerAudioTrack, buffer, info)
audioExtractor.advance()
}
} finally {
audioExtractor.release()
}
}
private fun canPassthroughAudio(format: MediaFormat): Boolean {
val mime = format.getString(MediaFormat.KEY_MIME) ?: return false
return mime == MediaFormat.MIMETYPE_AUDIO_AAC
}
private data class TranscodedAudio(
val outputFormat: MediaFormat,
val samples: List<Sample>
) {
data class Sample(
val bytes: ByteArray,
val presentationTimeUs: Long,
val flags: Int
)
}
// Re-encode non-AAC source audio (Opus, Vorbis, etc.) to AAC so the mp4 muxer
// can take it. iOS always re-encodes to AAC; without this Android would drop
// the audio track entirely.
private fun transcodeAudioToAAC(
audioTrackIndex: Int,
sourceFormat: MediaFormat
): TranscodedAudio? {
val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: return null
val sampleRate = if (sourceFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE))
sourceFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100
val channelCount = if (sourceFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT))
sourceFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT).coerceIn(1, 2) else 2
val audioExtractor = MediaExtractor()
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
} else {
audioExtractor.setDataSource(uriString)
}
audioExtractor.selectTrack(audioTrackIndex)
var decoder: MediaCodec? = null
var encoder: MediaCodec? = null
try {
decoder = MediaCodec.createDecoderByType(sourceMime)
decoder.configure(sourceFormat, null, null, 0)
decoder.start()
val encoderFormat = MediaFormat.createAudioFormat(
MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount
).apply {
setInteger(
MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC
)
setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 256 * 1024)
}
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
encoder.start()
val samples = mutableListOf<TranscodedAudio.Sample>()
var outputFormat: MediaFormat? = null
var inputDone = false
var decoderDone = false
var encoderInputSignalled = false
var encoderDone = false
val info = MediaCodec.BufferInfo()
while (!encoderDone && !isCancelled) {
if (!inputDone) {
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
if (idx >= 0) {
val buf = decoder.getInputBuffer(idx)
if (buf != null) {
val sz = audioExtractor.readSampleData(buf, 0)
if (sz < 0) {
decoder.queueInputBuffer(
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputDone = true
} else {
decoder.queueInputBuffer(
idx, 0, sz, audioExtractor.sampleTime, audioExtractor.sampleFlags
)
audioExtractor.advance()
}
}
}
}
if (!decoderDone) {
val status = decoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
when {
status == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
status == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {}
status >= 0 -> {
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
val data = decoder.getOutputBuffer(status)
if (data != null && info.size > 0) {
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
if (encInIdx >= 0) {
val encInBuf = encoder.getInputBuffer(encInIdx)
if (encInBuf != null) {
encInBuf.clear()
data.position(info.offset)
data.limit(info.offset + info.size)
encInBuf.put(data)
encoder.queueInputBuffer(
encInIdx, 0, info.size, info.presentationTimeUs, 0
)
}
}
}
decoder.releaseOutputBuffer(status, false)
if (isEos) {
if (!encoderInputSignalled) {
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE * 10)
if (encInIdx >= 0) {
encoder.queueInputBuffer(
encInIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
encoderInputSignalled = true
}
}
decoderDone = true
}
}
}
}
val encOutIdx = encoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
when {
encOutIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
encOutIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
outputFormat = encoder.outputFormat
}
encOutIdx >= 0 -> {
val data = encoder.getOutputBuffer(encOutIdx)
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
if (data != null && info.size > 0 && !isConfig) {
val bytes = ByteArray(info.size)
data.position(info.offset)
data.get(bytes, 0, info.size)
samples.add(
TranscodedAudio.Sample(
bytes = bytes,
presentationTimeUs = info.presentationTimeUs,
flags = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG.inv()
)
)
}
encoder.releaseOutputBuffer(encOutIdx, false)
if (isEos) encoderDone = true
}
}
}
val fmt = outputFormat ?: return null
return TranscodedAudio(fmt, samples)
} catch (e: Exception) {
Log.w(TAG, "Audio transcode failed; dropping audio", e)
return null
} finally {
try { decoder?.stop() } catch (_: Exception) {}
try { decoder?.release() } catch (_: Exception) {}
try { encoder?.stop() } catch (_: Exception) {}
try { encoder?.release() } catch (_: Exception) {}
audioExtractor.release()
}
}
private fun writeTranscodedAudio(
samples: List<TranscodedAudio.Sample>,
muxer: MediaMuxer,
muxerAudioTrack: Int
) {
val info = MediaCodec.BufferInfo()
for (sample in samples) {
if (isCancelled) break
val buffer = ByteBuffer.wrap(sample.bytes)
info.offset = 0
info.size = sample.bytes.size
info.presentationTimeUs = sample.presentationTimeUs
info.flags = sample.flags
muxer.writeSampleData(muxerAudioTrack, buffer, info)
}
}
private fun calculateOutputDims(srcW: Int, srcH: Int, rotation: Int, maxSize: Int): Pair<Int, Int> {
val isRotated = rotation == 90 || rotation == 270
val displayW = if (isRotated) srcH else srcW
val displayH = if (isRotated) srcW else srcH
val scale: Float = when {
displayW <= maxSize && displayH <= maxSize -> 1.0f
displayW > displayH -> maxSize.toFloat() / displayW
else -> maxSize.toFloat() / displayH
}
return Pair(
roundToEven((displayW * scale).toInt()),
roundToEven((displayH * scale).toInt())
)
}
private fun roundToEven(v: Int): Int = if (v % 2 == 0) v else v - 1
}
@@ -0,0 +1,125 @@
package expo.modules.blueskyvideocompress
import android.content.Context
import android.media.MediaExtractor
import android.media.MediaFormat
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
object VideoProber {
fun probe(context: Context, uriString: String): Map<String, Any> {
val uri = Uri.parse(uriString)
val retriever = MediaMetadataRetriever()
try {
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
retriever.setDataSource(context, uri)
} else {
retriever.setDataSource(uriString)
}
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
?.toIntOrNull() ?: 0
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
?.toIntOrNull() ?: 0
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull() ?: 0L
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
?.toIntOrNull() ?: 0
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
?.toIntOrNull() ?: 0
val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
?.equals("yes") ?: false
val frameRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
?.toFloatOrNull() ?: 0f
val fileSize = getFileSize(context, uriString)
val extractor = MediaExtractor()
var codec = "unknown"
var mimeType = "video/mp4"
var extractedFrameRate = frameRate
var isHDR = false
try {
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
extractor.setDataSource(context, uri, null)
} else {
extractor.setDataSource(uriString)
}
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME)
if (mime?.startsWith("video/") == true) {
mimeType = mime
codec = mime.removePrefix("video/")
if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) {
extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
format.containsKey(MediaFormat.KEY_COLOR_TRANSFER)
) {
val transfer = format.getInteger(MediaFormat.KEY_COLOR_TRANSFER)
isHDR = transfer == MediaFormat.COLOR_TRANSFER_HLG ||
transfer == MediaFormat.COLOR_TRANSFER_ST2084
}
// Dolby Vision tracks use codec-specific mimes that aren't covered by
// KEY_COLOR_TRANSFER on every device.
if (mime.contains("dolby-vision", ignoreCase = true)) {
isHDR = true
}
break
}
}
} finally {
extractor.release()
}
val durationSeconds = durationMs / 1000.0
val effectiveBitrate = if (bitrate > 0) {
bitrate
} else if (durationSeconds > 0 && fileSize > 0) {
(fileSize * 8 / durationSeconds).toInt()
} else {
0
}
return mapOf(
"width" to width,
"height" to height,
"duration" to durationSeconds,
"bitrate" to effectiveBitrate,
"fileSize" to fileSize,
"mimeType" to mimeType,
"codec" to codec,
"hasAudio" to hasAudio,
"frameRate" to extractedFrameRate.toDouble(),
"rotation" to rotation,
"isHDR" to isHDR
)
} finally {
retriever.release()
}
}
private fun getFileSize(context: Context, uriString: String): Long {
return try {
if (uriString.startsWith("content://")) {
context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r")?.use {
it.statSize
} ?: 0L
} else {
val path = if (uriString.startsWith("file://")) {
uriString.removePrefix("file://")
} else {
uriString
}
java.io.File(path).length()
}
} catch (_: Exception) {
0L
}
}
}
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["ExpoBlueskyVideoCompressModule"]
},
"android": {
"modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"]
}
}
@@ -0,0 +1,87 @@
import {type EventSubscription} from 'expo-modules-core'
import NativeModule from './src/ExpoBlueskyVideoCompressModule'
import {
type CodecPreference,
type CompressCallbacks,
type CompressOptions,
type CompressResult,
type VideoMetadata,
} from './src/types'
export type {
CodecPreference,
CompressCallbacks,
CompressOptions,
CompressResult,
VideoMetadata,
}
class AbortError extends Error {
name = 'AbortError'
constructor() {
super('Aborted')
}
}
let jobIdCounter = 0
export function probe(uri: string): Promise<VideoMetadata> {
return NativeModule.probe(uri)
}
export function compress(
uri: string,
options: CompressOptions = {},
callbacks?: CompressCallbacks,
): Promise<CompressResult> {
const jobId = ++jobIdCounter
let subscription: EventSubscription | undefined
if (callbacks?.signal?.aborted) {
return Promise.reject(new AbortError())
}
const nativeOptions = {
targetBitrate: options.targetBitrate ?? 0,
maxSize: options.maxSize ?? 1920,
codec: options.codec ?? 'auto',
frameRateCap: options.frameRateCap ?? 30,
jobId,
}
return new Promise<CompressResult>((resolve, reject) => {
if (callbacks?.onProgress) {
subscription = NativeModule.addListener(
'onProgress',
(event: {id: number; progress: number}) => {
if (event.id === jobId) {
callbacks.onProgress!(event.progress)
}
},
)
}
const abortHandler = () => {
NativeModule.cancel(jobId)
subscription?.remove()
reject(new AbortError())
}
if (callbacks?.signal) {
callbacks.signal.addEventListener('abort', abortHandler, {once: true})
}
NativeModule.compress(uri, nativeOptions)
.then(result => {
callbacks?.signal?.removeEventListener('abort', abortHandler)
subscription?.remove()
resolve(result)
})
.catch((error: unknown) => {
callbacks?.signal?.removeEventListener('abort', abortHandler)
subscription?.remove()
reject(error instanceof Error ? error : new Error(String(error)))
})
})
}
@@ -0,0 +1,21 @@
import VideoToolbox
enum CodecCapability {
static let isHardwareHEVCEncodeAvailable: Bool = {
var encoderListCF: CFArray?
let status = VTCopyVideoEncoderList(nil, &encoderListCF)
guard status == noErr, let encoderList = encoderListCF as? [[String: Any]] else {
return false
}
return encoderList.contains { encoder in
guard let codecTypeValue = encoder[kVTVideoEncoderList_CodecType as String] as? Int,
codecTypeValue == Int(kCMVideoCodecType_HEVC) else {
return false
}
if let isHardware = encoder[kVTVideoEncoderList_IsHardwareAccelerated as String] as? Bool {
return isHardware
}
return true
}
}()
}
@@ -0,0 +1,20 @@
Pod::Spec.new do |s|
s.name = 'ExpoBlueskyVideoCompress'
s.version = '1.0.0'
s.summary = 'Hardware-accelerated video compression for Bluesky'
s.description = 'Hardware-accelerated h264/HEVC video compression using AVAssetReader/Writer and VideoToolbox on iOS'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '15.1' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -0,0 +1,74 @@
import AVFoundation
import ExpoModulesCore
public class ExpoBlueskyVideoCompressModule: Module {
private var activeCompressors: [Int: VideoCompressor] = [:]
private let activeCompressorsLock = NSLock()
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyVideoCompress")
Events("onProgress")
AsyncFunction("probe") { (uri: String) -> [String: Any] in
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
return try await VideoProber.probe(url: url)
}
AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
let targetBitrate = options["targetBitrate"] as? Int ?? 0
let maxSize = options["maxSize"] as? Int ?? 1920
let codecPref = options["codec"] as? String ?? "auto"
let frameRateCap = max(1, options["frameRateCap"] as? Int ?? 30)
let jobId = options["jobId"] as? Int ?? 0
let compressor = VideoCompressor(
url: url,
targetBitrate: targetBitrate,
maxSize: maxSize,
codecPref: codecPref,
frameRateCap: frameRateCap,
jobId: jobId,
onProgress: { [weak self] id, progress in
self?.sendEvent("onProgress", [
"id": id,
"progress": progress,
])
}
)
self.setCompressor(jobId, compressor)
do {
let result = try await compressor.compress()
self.setCompressor(jobId, nil)
return result
} catch {
self.setCompressor(jobId, nil)
throw error
}
}
Function("cancel") { (jobId: Int) in
self.cancelCompressor(jobId)
}
}
private func setCompressor(_ jobId: Int, _ compressor: VideoCompressor?) {
activeCompressorsLock.lock()
defer { activeCompressorsLock.unlock() }
if let compressor = compressor {
activeCompressors[jobId] = compressor
} else {
activeCompressors.removeValue(forKey: jobId)
}
}
private func cancelCompressor(_ jobId: Int) {
activeCompressorsLock.lock()
let compressor = activeCompressors.removeValue(forKey: jobId)
activeCompressorsLock.unlock()
compressor?.cancel()
}
}
@@ -0,0 +1,410 @@
import AVFoundation
import VideoToolbox
class VideoCompressor {
private let url: URL
private let targetBitrate: Int
private let maxSize: Int
private let codecPref: String
private let frameRateCap: Int
private let jobId: Int
private let onProgress: (Int, Double) -> Void
private var isCancelled = false
init(
url: URL,
targetBitrate: Int,
maxSize: Int,
codecPref: String,
frameRateCap: Int,
jobId: Int,
onProgress: @escaping (Int, Double) -> Void
) {
self.url = url
self.targetBitrate = targetBitrate
self.maxSize = maxSize
self.codecPref = codecPref
self.frameRateCap = frameRateCap
self.jobId = jobId
self.onProgress = onProgress
}
func cancel() {
isCancelled = true
}
func compress() async throws -> [String: Any] {
let asset = AVURLAsset(
url: url,
options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]
)
let duration = try await asset.load(.duration)
let totalSeconds = CMTimeGetSeconds(duration)
guard totalSeconds > 0 else { throw err("Invalid video duration", code: 2) }
let videoTracks = try await asset.loadTracks(withMediaType: .video)
guard let videoTrack = videoTracks.first else {
throw err("No video track found", code: 1)
}
let naturalSize = try await videoTrack.load(.naturalSize)
let preferredTransform = try await videoTrack.load(.preferredTransform)
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
let displaySize = CGSize(
width: abs(rotatedRect.width),
height: abs(rotatedRect.height)
)
let outputSize = scaleEvenly(displaySize: displaySize, maxSize: maxSize)
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
let useHEVC: Bool
switch codecPref {
case "hevc": useHEVC = true
default: useHEVC = false
}
let codecType: AVVideoCodecType = useHEVC ? .hevc : .h264
let profileLevel: String = useHEVC
? kVTProfileLevel_HEVC_Main_AutoLevel as String
: kVTProfileLevel_H264_High_AutoLevel as String
let effectiveBitrate = targetBitrate > 0
? targetBitrate
: (useHEVC ? 2_500_000 : 3_000_000)
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mp4")
let videoComposition = makeRotatingComposition(
videoTrack: videoTrack,
preferredTransform: preferredTransform,
naturalSize: naturalSize,
outputSize: outputSize,
duration: duration
)
let reader = try AVAssetReader(asset: asset)
let videoReaderSettings: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
let videoReaderOutput = AVAssetReaderVideoCompositionOutput(
videoTracks: [videoTrack],
videoSettings: videoReaderSettings
)
videoReaderOutput.videoComposition = videoComposition
videoReaderOutput.alwaysCopiesSampleData = false
guard reader.canAdd(videoReaderOutput) else {
throw err("Cannot read video track", code: 3)
}
reader.add(videoReaderOutput)
var compressionProps: [String: Any] = [
AVVideoAverageBitRateKey: effectiveBitrate,
AVVideoProfileLevelKey: profileLevel,
AVVideoMaxKeyFrameIntervalKey: max(frameRateCap * 3, 30),
AVVideoExpectedSourceFrameRateKey: frameRateCap,
AVVideoAllowFrameReorderingKey: false,
kVTCompressionPropertyKey_RealTime as String: true,
]
let peakBytesPerSecond = Int(Double(effectiveBitrate) / 8.0 * 1.5)
compressionProps[kVTCompressionPropertyKey_DataRateLimits as String] = [
peakBytesPerSecond, 1.0
] as CFArray
let videoColorProps: [String: Any] = [
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
]
let videoWriterSettings: [String: Any] = [
AVVideoCodecKey: codecType,
AVVideoWidthKey: outputSize.width,
AVVideoHeightKey: outputSize.height,
AVVideoColorPropertiesKey: videoColorProps,
AVVideoCompressionPropertiesKey: compressionProps,
]
let videoWriterInput = AVAssetWriterInput(
mediaType: .video,
outputSettings: videoWriterSettings
)
videoWriterInput.expectsMediaDataInRealTime = false
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
writer.shouldOptimizeForNetworkUse = true
writer.metadata = []
guard writer.canAdd(videoWriterInput) else {
throw err("Cannot write video track", code: 4)
}
writer.add(videoWriterInput)
var audioReaderOutput: AVAssetReaderTrackOutput?
var audioWriterInput: AVAssetWriterInput?
if let audioTrack = audioTracks.first {
let audioDecoderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatLinearPCM,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsNonInterleaved: false,
]
let audioOutput = AVAssetReaderTrackOutput(
track: audioTrack,
outputSettings: audioDecoderSettings
)
audioOutput.alwaysCopiesSampleData = false
if reader.canAdd(audioOutput) {
reader.add(audioOutput)
audioReaderOutput = audioOutput
let audioEncoderSettings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVEncoderBitRateKey: 128_000,
]
let audioInput = AVAssetWriterInput(
mediaType: .audio,
outputSettings: audioEncoderSettings
)
audioInput.expectsMediaDataInRealTime = false
if writer.canAdd(audioInput) {
writer.add(audioInput)
audioWriterInput = audioInput
}
}
}
guard reader.startReading() else {
throw reader.error ?? err("Reader failed to start", code: 8)
}
guard writer.startWriting() else {
throw writer.error ?? err("Writer failed to start", code: 9)
}
writer.startSession(atSourceTime: .zero)
let minFrameIntervalSeconds = 1.0 / Double(frameRateCap)
let minFrameInterval = CMTime(
seconds: minFrameIntervalSeconds,
preferredTimescale: 600
)
await withTaskGroup(of: Void.self) { group in
group.addTask { [self] in
await processVideoTrack(
readerOutput: videoReaderOutput,
writerInput: videoWriterInput,
totalDuration: totalSeconds,
minFrameInterval: minFrameInterval
)
}
if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput {
group.addTask { [self] in
await processAudioTrack(
readerOutput: audioOutput,
writerInput: audioInput
)
}
}
await group.waitForAll()
}
if isCancelled {
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
throw err("Compression cancelled", code: 5)
}
if reader.status == .failed {
let error = reader.error ?? err("Reader failed", code: 6)
writer.cancelWriting()
try? FileManager.default.removeItem(at: outputURL)
throw error
}
await writer.finishWriting()
if writer.status == .failed {
let error = writer.error ?? err("Writer failed", code: 7)
try? FileManager.default.removeItem(at: outputURL)
throw error
}
onProgress(jobId, 1.0)
let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
let fileSize = attributes[.size] as? Int ?? 0
return [
"uri": outputURL.absoluteString,
"size": fileSize,
"mimeType": "video/mp4",
"width": outputSize.width,
"height": outputSize.height,
"duration": totalSeconds,
"codec": useHEVC ? "hevc" : "h264",
]
}
private func processVideoTrack(
readerOutput: AVAssetReaderOutput,
writerInput: AVAssetWriterInput,
totalDuration: Double,
minFrameInterval: CMTime
) async {
var lastProgressTime: CFAbsoluteTime = 0
var lastAppendedPTS: CMTime?
var finished = false
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
writerInput.requestMediaDataWhenReady(
on: DispatchQueue(label: "com.bsky.videocompress.video")
) {
let finish = {
if !finished {
finished = true
writerInput.markAsFinished()
continuation.resume()
}
}
while writerInput.isReadyForMoreMediaData {
if finished { return }
if self.isCancelled {
finish()
return
}
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
finish()
return
}
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
if let last = lastAppendedPTS {
let delta = CMTimeSubtract(pts, last)
if CMTimeCompare(delta, minFrameInterval) < 0 {
continue
}
}
lastAppendedPTS = pts
if !writerInput.append(sampleBuffer) {
finish()
return
}
let now = CFAbsoluteTimeGetCurrent()
if now - lastProgressTime >= 0.1 {
lastProgressTime = now
let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0)
self.onProgress(self.jobId, progress)
}
}
}
}
}
private func processAudioTrack(
readerOutput: AVAssetReaderOutput,
writerInput: AVAssetWriterInput
) async {
var finished = false
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
writerInput.requestMediaDataWhenReady(
on: DispatchQueue(label: "com.bsky.videocompress.audio")
) {
let finish = {
if !finished {
finished = true
writerInput.markAsFinished()
continuation.resume()
}
}
while writerInput.isReadyForMoreMediaData {
if finished { return }
if self.isCancelled {
finish()
return
}
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
finish()
return
}
if !writerInput.append(sampleBuffer) {
finish()
return
}
}
}
}
}
private func makeRotatingComposition(
videoTrack: AVAssetTrack,
preferredTransform: CGAffineTransform,
naturalSize: CGSize,
outputSize: (width: Int, height: Int),
duration: CMTime
) -> AVMutableVideoComposition {
let composition = AVMutableVideoComposition()
composition.renderSize = CGSize(width: outputSize.width, height: outputSize.height)
composition.frameDuration = CMTime(value: 1, timescale: Int32(frameRateCap))
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
let translate = CGAffineTransform(
translationX: -rotatedRect.minX,
y: -rotatedRect.minY
)
let displaySize = CGSize(
width: abs(rotatedRect.width),
height: abs(rotatedRect.height)
)
let scaleX = CGFloat(outputSize.width) / displaySize.width
let scaleY = CGFloat(outputSize.height) / displaySize.height
let scale = CGAffineTransform(scaleX: scaleX, y: scaleY)
let combined = preferredTransform.concatenating(translate).concatenating(scale)
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
layerInstruction.setTransform(combined, at: .zero)
let instruction = AVMutableVideoCompositionInstruction()
instruction.timeRange = CMTimeRange(start: .zero, duration: duration)
instruction.layerInstructions = [layerInstruction]
composition.instructions = [instruction]
return composition
}
private func scaleEvenly(displaySize: CGSize, maxSize: Int) -> (width: Int, height: Int) {
let cap = CGFloat(maxSize)
let scale: CGFloat
if displaySize.width <= cap && displaySize.height <= cap {
scale = 1.0
} else if displaySize.width > displaySize.height {
scale = cap / displaySize.width
} else {
scale = cap / displaySize.height
}
return (
roundToEven(Int(displaySize.width * scale)),
roundToEven(Int(displaySize.height * scale))
)
}
private func roundToEven(_ value: Int) -> Int {
return value % 2 == 0 ? value : value - 1
}
private func err(_ message: String, code: Int) -> NSError {
return NSError(
domain: "ExpoBlueskyVideoCompress",
code: code,
userInfo: [NSLocalizedDescriptionKey: message]
)
}
}
@@ -0,0 +1,105 @@
import AVFoundation
import UniformTypeIdentifiers
struct VideoProber {
static func probe(url: URL) async throws -> [String: Any] {
let asset = AVURLAsset(url: url)
let duration = try await asset.load(.duration)
let tracks = try await asset.loadTracks(withMediaType: .video)
guard let videoTrack = tracks.first else {
throw NSError(
domain: "ExpoBlueskyVideoCompress",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
)
}
let naturalSize = try await videoTrack.load(.naturalSize)
let preferredTransform = try await videoTrack.load(.preferredTransform)
let estimatedDataRate = try await videoTrack.load(.estimatedDataRate)
let nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
var codec = "unknown"
var isHDR = false
if let formatDescription = formatDescriptions.first {
let subType = CMFormatDescriptionGetMediaSubType(formatDescription)
codec = fourCCToString(subType)
// Dolby Vision codecs are HDR by definition.
let dolbyVisionSubtypes: Set<String> = ["dvhe", "dvh1", "dvav", "dva1"]
if dolbyVisionSubtypes.contains(codec) {
isHDR = true
} else if let extensions = CMFormatDescriptionGetExtensions(formatDescription)
as? [String: Any]
{
let transferKey = kCMFormatDescriptionExtension_TransferFunction as String
if let transfer = extensions[transferKey] as? String {
let hlg = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG as String
let pq = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ as String
isHDR = transfer == hlg || transfer == pq
}
}
}
let rotation = rotationFromTransform(preferredTransform)
let isRotated = rotation == 90 || rotation == 270
let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width)
let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height)
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
let hasAudio = !audioTracks.isEmpty
let fileSize: Int
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attributes[.size] as? Int {
fileSize = size
} else {
fileSize = 0
}
let mimeType: String
if let utType = UTType(filenameExtension: url.pathExtension) {
mimeType = utType.preferredMIMEType ?? "video/mp4"
} else {
mimeType = "video/mp4"
}
let durationSeconds = CMTimeGetSeconds(duration)
var bitrate = Int(estimatedDataRate)
if bitrate == 0 && durationSeconds > 0 {
bitrate = Int(Double(fileSize * 8) / durationSeconds)
}
return [
"width": width,
"height": height,
"duration": durationSeconds,
"bitrate": bitrate,
"fileSize": fileSize,
"mimeType": mimeType,
"codec": codec,
"hasAudio": hasAudio,
"frameRate": nominalFrameRate,
"rotation": rotation,
"isHDR": isHDR
]
}
private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int {
let angle = atan2(transform.b, transform.a)
let degrees = Int(round(angle * 180.0 / .pi))
return ((degrees % 360) + 360) % 360
}
private static func fourCCToString(_ code: FourCharCode) -> String {
let chars: [Character] = [
Character(UnicodeScalar((code >> 24) & 0xFF)!),
Character(UnicodeScalar((code >> 16) & 0xFF)!),
Character(UnicodeScalar((code >> 8) & 0xFF)!),
Character(UnicodeScalar(code & 0xFF)!)
]
return String(chars).trimmingCharacters(in: .whitespaces)
}
}
@@ -0,0 +1,23 @@
import {type EventSubscription, requireNativeModule} from 'expo-modules-core'
import {
type CompressResult,
type NativeCompressOptions,
type VideoMetadata,
} from './types'
type ProgressEvent = {id: number; progress: number}
interface ExpoBlueskyVideoCompressModule {
probe(uri: string): Promise<VideoMetadata>
compress(uri: string, options: NativeCompressOptions): Promise<CompressResult>
cancel(jobId: number): void
addListener(
eventName: 'onProgress',
listener: (event: ProgressEvent) => void,
): EventSubscription
}
export default requireNativeModule<ExpoBlueskyVideoCompressModule>(
'ExpoBlueskyVideoCompress',
)
@@ -0,0 +1,45 @@
export type CodecPreference = 'auto' | 'hevc' | 'h264'
export type VideoMetadata = {
width: number
height: number
duration: number
bitrate: number
fileSize: number
mimeType: string
codec: string
hasAudio: boolean
frameRate: number
rotation: number
isHDR: boolean
}
export type CompressOptions = {
targetBitrate?: number
maxSize?: number
codec?: CodecPreference
frameRateCap?: number
}
export type CompressCallbacks = {
onProgress?: (progress: number) => void
signal?: AbortSignal
}
export type CompressResult = {
uri: string
size: number
mimeType: string
width: number
height: number
duration: number
codec: 'h264' | 'hevc'
}
export type NativeCompressOptions = {
targetBitrate: number
maxSize: number
codec: CodecPreference
frameRateCap: number
jobId: number
}
+2 -3
View File
@@ -94,8 +94,8 @@
"update-actions": "pnpm dlx actions-up --min-age 7" "update-actions": "pnpm dlx actions-up --min-age 7"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.20", "@atproto/api": "0.20.22",
"@atproto/syntax": "0.6.1", "@atproto/syntax": "0.6.3",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14", "@bsky.app/alf": "^0.1.14",
@@ -296,7 +296,6 @@
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"lint-staged": "^13.2.3", "lint-staged": "^13.2.3",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"react-native-dotenv": "^3.4.11",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"svgo": "^3.3.2", "svgo": "^3.3.2",
"ts-plugin-sort-import-suggestions": "^1.0.4", "ts-plugin-sort-import-suggestions": "^1.0.4",
+36 -58
View File
@@ -241,11 +241,11 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: 0.20.20 specifier: 0.20.22
version: 0.20.20 version: 0.20.22
'@atproto/syntax': '@atproto/syntax':
specifier: 0.6.1 specifier: 0.6.3
version: 0.6.1 version: 0.6.3
'@bitdrift/react-native': '@bitdrift/react-native':
specifier: ^0.6.8 specifier: ^0.6.8
version: 0.6.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) version: 0.6.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -844,9 +844,6 @@ importers:
prettier: prettier:
specifier: ^3.8.3 specifier: ^3.8.3
version: 3.8.3 version: 3.8.3
react-native-dotenv:
specifier: ^3.4.11
version: 3.4.11(@babel/runtime@7.29.2)
react-refresh: react-refresh:
specifier: ^0.14.0 specifier: ^0.14.0
version: 0.14.2 version: 0.14.2
@@ -876,36 +873,32 @@ packages:
graphql: graphql:
optional: true optional: true
'@atproto/api@0.20.20': '@atproto/api@0.20.22':
resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==} resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.1': '@atproto/common-web@0.5.2':
resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==} resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lex-data@0.1.2': '@atproto/lex-data@0.1.3':
resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==} resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lex-json@0.1.1': '@atproto/lex-json@0.1.2':
resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==} resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/lexicon@0.7.2': '@atproto/lexicon@0.7.3':
resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==} resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/syntax@0.6.1': '@atproto/syntax@0.6.3':
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==} resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/syntax@0.6.2': '@atproto/xrpc@0.8.2':
resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==} resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.1':
resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@babel/code-frame@7.10.4': '@babel/code-frame@7.10.4':
@@ -8002,11 +7995,6 @@ packages:
react: '*' react: '*'
react-native: '*' react-native: '*'
react-native-dotenv@3.4.11:
resolution: {integrity: sha512-6vnIE+WHABSeHCaYP6l3O1BOEhWxKH6nHAdV7n/wKn/sciZ64zPPp2NUdEUf1m7g4uuzlLbjgr+6uDt89q2DOg==}
peerDependencies:
'@babel/runtime': ^7.20.6
react-native-drawer-layout@4.2.3: react-native-drawer-layout@4.2.3:
resolution: {integrity: sha512-mj/riptO4O8D9loG9N76M4VqLR9EQXLQkx/nN4GVgT/y46wpVlrqqLlDAqFVylTDEW/u3WvZhZph+8Iu64MsZw==} resolution: {integrity: sha512-mj/riptO4O8D9loG9N76M4VqLR9EQXLQkx/nN4GVgT/y46wpVlrqqLlDAqFVylTDEW/u3WvZhZph+8Iu64MsZw==}
peerDependencies: peerDependencies:
@@ -9476,56 +9464,51 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {} '@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.20': '@atproto/api@0.20.22':
dependencies: dependencies:
'@atproto/common-web': 0.5.1 '@atproto/common-web': 0.5.2
'@atproto/lexicon': 0.7.2 '@atproto/lexicon': 0.7.3
'@atproto/syntax': 0.6.2 '@atproto/syntax': 0.6.3
'@atproto/xrpc': 0.8.1 '@atproto/xrpc': 0.8.2
await-lock: 3.0.0 await-lock: 3.0.0
multiformats: 13.4.2 multiformats: 13.4.2
tlds: 1.261.0 tlds: 1.261.0
zod: 3.25.76 zod: 3.25.76
'@atproto/common-web@0.5.1': '@atproto/common-web@0.5.2':
dependencies: dependencies:
'@atproto/lex-data': 0.1.2 '@atproto/lex-data': 0.1.3
'@atproto/lex-json': 0.1.1 '@atproto/lex-json': 0.1.2
'@atproto/syntax': 0.6.2 '@atproto/syntax': 0.6.3
zod: 3.25.76 zod: 3.25.76
'@atproto/lex-data@0.1.2': '@atproto/lex-data@0.1.3':
dependencies: dependencies:
multiformats: 13.4.2 multiformats: 13.4.2
tslib: 2.8.1 tslib: 2.8.1
uint8arrays: 5.1.1 uint8arrays: 5.1.1
unicode-segmenter: 0.14.5 unicode-segmenter: 0.14.5
'@atproto/lex-json@0.1.1': '@atproto/lex-json@0.1.2':
dependencies: dependencies:
'@atproto/lex-data': 0.1.2 '@atproto/lex-data': 0.1.3
tslib: 2.8.1 tslib: 2.8.1
'@atproto/lexicon@0.7.2': '@atproto/lexicon@0.7.3':
dependencies: dependencies:
'@atproto/common-web': 0.5.1 '@atproto/common-web': 0.5.2
'@atproto/syntax': 0.6.2 '@atproto/syntax': 0.6.3
multiformats: 13.4.2 multiformats: 13.4.2
zod: 3.25.76 zod: 3.25.76
'@atproto/syntax@0.6.1': '@atproto/syntax@0.6.3':
dependencies: dependencies:
iso-datestring-validator: 2.2.2 iso-datestring-validator: 2.2.2
tslib: 2.8.1 tslib: 2.8.1
'@atproto/syntax@0.6.2': '@atproto/xrpc@0.8.2':
dependencies: dependencies:
iso-datestring-validator: 2.2.2 '@atproto/lexicon': 0.7.3
tslib: 2.8.1
'@atproto/xrpc@0.8.1':
dependencies:
'@atproto/lexicon': 0.7.2
zod: 3.25.76 zod: 3.25.76
'@babel/code-frame@7.10.4': '@babel/code-frame@7.10.4':
@@ -17845,11 +17828,6 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0) react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
react-native-dotenv@3.4.11(@babel/runtime@7.29.2):
dependencies:
'@babel/runtime': 7.29.2
dotenv: 16.6.1
react-native-drawer-layout@4.2.3(patch_hash=74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130)(react-native-gesture-handler@2.28.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): react-native-drawer-layout@4.2.3(patch_hash=74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130)(react-native-gesture-handler@2.28.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies: dependencies:
color: 4.2.3 color: 4.2.3
View File
+2
View File
@@ -13,6 +13,8 @@ export enum Features {
ComposerLanguageDetectionEnable = 'composer:language_detection:enable', ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable', PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable', NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
SearchV2Enable = 'search_v2:enable',
AdvancedSearchV2Enable = 'advanced_search_v2:enable',
AATest = 'aa-test', AATest = 'aa-test',
} }
+63 -5
View File
@@ -1,5 +1,6 @@
import {createContext, useContext, useMemo} from 'react' import {createContext, useContext, useMemo} from 'react'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {type Result} from '@growthbook/growthbook-react'
import {Logger} from '#/logger' import {Logger} from '#/logger'
import { import {
@@ -169,6 +170,39 @@ export function AnalyticsContext({
return <Context.Provider value={childContext}>{children}</Context.Provider> return <Context.Provider value={childContext}>{children}</Context.Provider>
} }
/**
* GrowthBook attribute name for the did. Must match the key used in
* `setAttributes` (`#/analytics/features`) and the assignment unit configured
* in the GrowthBook dashboard.
*/
const DID_HASH_ATTRIBUTE = 'did'
/**
* Builds the session metadata override for an exposure event so the event's
* `did` is sourced from the unit GrowthBook actually bucketed on
* (`result.hashValue`) rather than from ambient React session metadata. This
* keeps the `did` and the variation in sync, since both then come from the
* same evaluation. See APP-2461.
*
* Only did-bucketed experiments are overridden. Experiments bucketed on
* another attribute (e.g. `deviceId`) have a `hashValue` that is not a did, so
* we leave their session metadata untouched and let the ambient did stand.
*/
function sessionMetadataForResult(
parentContext: AnalyticsBaseContextType,
result: Result<unknown>,
): MergeableMetadata | undefined {
if (result.hashAttribute !== DID_HASH_ATTRIBUTE) return undefined
const {session} = parentContext.metadata
if (!session) return undefined
return {
session: {
...session,
did: result.hashValue,
},
}
}
/** /**
* Feature gates provider. Decorates the parent analytics context with * Feature gates provider. Decorates the parent analytics context with
* feature gate capabilities. Should be mounted within `AnalyticsContext`, * feature gate capabilities. Should be mounted within `AnalyticsContext`,
@@ -185,22 +219,46 @@ export function AnalyticsFeaturesContext({
* Side-effects: we need to synchronously set these during the same render * Side-effects: we need to synchronously set these during the same render
* cycle. These calls do not trigger re-renders, they just set properties on * cycle. These calls do not trigger re-renders, they just set properties on
* the singleton GrowthBook instance. * the singleton GrowthBook instance.
*
* Order matters here. We register the tracking callbacks _before_ calling
* `setAttributes`, because `setAttributes` triggers a synchronous
* re-evaluation that can fire exposure events. Registering first guarantees
* those events run through this render's callback (with this render's
* metadata) rather than a stale callback left over from a previous render,
* e.g. after an account switch remounts this provider via the
* `<Fragment key={did} />` breaker in `App.<platform>.tsx`. See APP-2461.
*
* We deliberately keep these synchronous rather than moving them into a
* `useEffect`: `setAttributes` must run before children evaluate gates (or
* they bucket on the previous account's attributes), and the feature usage
* callback has no deferred-replay, so a feature evaluated before it is
* registered would lose its `feature:viewed` event entirely.
*/ */
setAttributes(parentContext.metadata)
feats.setTrackingCallback((experiment, result) => { feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', { parentContext.metric(
'experiment:viewed',
{
experimentId: experiment.key, experimentId: experiment.key,
variationId: result.key, variationId: result.key,
}) },
sessionMetadataForResult(parentContext, result),
)
}) })
feats.setFeatureUsageCallback((feature, result) => { feats.setFeatureUsageCallback((feature, result) => {
parentContext.metric('feature:viewed', { parentContext.metric(
'feature:viewed',
{
featureId: feature, featureId: feature,
featureResultValue: result.value, featureResultValue: result.value,
experimentId: result.experiment?.key, experimentId: result.experiment?.key,
variationId: result.experimentResult?.key, variationId: result.experimentResult?.key,
},
result.experimentResult
? sessionMetadataForResult(parentContext, result.experimentResult)
: undefined,
)
}) })
}) setAttributes(parentContext.metadata)
const childContext = useMemo<AnalyticsContextType>(() => { const childContext = useMemo<AnalyticsContextType>(() => {
return { return {
+32
View File
@@ -775,6 +775,7 @@ export type Events = {
'search:query': { 'search:query': {
source: 'typed' | 'history' | 'autocomplete' source: 'typed' | 'history' | 'autocomplete'
filterCount: number
} }
'search:results:loaded': { 'search:results:loaded': {
@@ -799,6 +800,18 @@ export type Events = {
position: number position: number
} }
'search:advanced:press': {
filterCount: number
}
'search:shareLink:press': {
filterCount: number
}
'search:addFilter:press': {
filterCount: number
}
'progressGuide:hide': {} 'progressGuide:hide': {}
'progressGuide:followDialog:open': {} 'progressGuide:followDialog:open': {}
@@ -1352,6 +1365,25 @@ export type Events = {
engine: string engine: string
sourceBytes?: number sourceBytes?: number
} }
// Native-only. Raw container metadata returned by the new module's probe()
// (bitrate, codec, HDR, frame rate, rotation, etc.). Fires once per upload
// between compressStarted and the compressSkipped/compressCompleted decision.
// The web (mediabunny) and legacy rn-compressor engines do not surface this.
'video:upload:probed': {
uploadId: string
engine: string
mimeType: string
codec: string
width: number
height: number
duration: number
bitrate: number
fileSize: number
hasAudio: boolean
frameRate: number
rotation: number
isHDR: boolean
}
'video:upload:compressCompleted': { 'video:upload:compressCompleted': {
uploadId: string uploadId: string
engine: string engine: string
+13 -18
View File
@@ -1,5 +1,4 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {View} from 'react-native'
import {Sift, type UseSiftReturn} from '@bsky.app/sift' import {Sift, type UseSiftReturn} from '@bsky.app/sift'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@@ -22,7 +21,7 @@ function renderItem(
case 'search': case 'search':
return <AutocompleteItemSearch {...item} /> return <AutocompleteItemSearch {...item} />
default: default:
return <View /> return <></>
} }
} }
@@ -33,6 +32,7 @@ export function Autocomplete({
render = renderItem, render = renderItem,
onSelect, onSelect,
onDismiss, onDismiss,
fullWidth = false,
}: { }: {
inverted?: boolean inverted?: boolean
sift: UseSiftReturn sift: UseSiftReturn
@@ -40,16 +40,24 @@ export function Autocomplete({
render?: Parameters<typeof Sift<AutocompleteItem>>[0]['render'] render?: Parameters<typeof Sift<AutocompleteItem>>[0]['render']
onSelect: (item: AutocompleteItem) => void onSelect: (item: AutocompleteItem) => void
onDismiss: () => void onDismiss: () => void
/**
* Match the anchor's width instead of the default capped width. Use for
* full-width anchors like the search bar; leave off for inline mention
* inputs.
*/
fullWidth?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
sift.updatePosition() void sift.updatePosition()
}, [sift]) }, [sift])
useOnKeyboard('keyboardDidShow', updatePosition) useOnKeyboard('keyboardDidShow', updatePosition)
useOnKeyboard('keyboardDidHide', updatePosition) useOnKeyboard('keyboardDidHide', updatePosition)
const maxWidth = IS_WEB && !fullWidth ? {maxWidth: 300} : {}
return ( return (
<Portal> <Portal>
<Sift <Sift
@@ -58,16 +66,7 @@ export function Autocomplete({
data={data} data={data}
onSelect={onSelect} onSelect={onSelect}
onDismiss={onDismiss} onDismiss={onDismiss}
outerStyle={[ outerStyle={[a.rounded_md, a.w_full, t.atoms.shadow_lg, maxWidth]}
a.rounded_md,
a.w_full,
t.atoms.shadow_lg,
IS_WEB
? {
maxWidth: 300,
}
: {},
]}
innerStyle={[ innerStyle={[
a.overflow_hidden, a.overflow_hidden,
a.rounded_md, a.rounded_md,
@@ -75,11 +74,7 @@ export function Autocomplete({
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
t.atoms.bg, t.atoms.bg,
a.w_full, a.w_full,
IS_WEB maxWidth,
? {
maxWidth: 300,
}
: {},
]} ]}
render={render} render={render}
/> />
+1
View File
@@ -45,4 +45,5 @@ export type AutocompleteItemProps = Parameters<
export type AutocompleteApi = { export type AutocompleteApi = {
query: string query: string
items: AutocompleteItem[] items: AutocompleteItem[]
isFetching: boolean
} }
@@ -118,6 +118,7 @@ export function useAutocomplete({
return { return {
query: q, query: q,
items, items,
isFetching: query.isFetching,
} }
} }
@@ -139,8 +139,7 @@ export function WizardProfileCard({
const isTarget = profile.did === targetProfileDid const isTarget = profile.did === targetProfileDid
const included = isTarget || state.profiles.some(p => p.did === profile.did) const included = isTarget || state.profiles.some(p => p.did === profile.did)
const disabled = const disabled =
isTarget || isTarget || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE)
(!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar') const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName) ? sanitizeDisplayName(profile.displayName)
+1
View File
@@ -8,6 +8,7 @@ export const KWS_SUPPORTED_LANGS = [
{value: 'en', label: 'English'}, {value: 'en', label: 'English'},
{value: 'ar', label: 'العربية'}, {value: 'ar', label: 'العربية'},
{value: 'zh-Hans', label: '简体中文'}, {value: 'zh-Hans', label: '简体中文'},
{value: 'cs', label: 'Čeština'},
{value: 'nl', label: 'Nederlands'}, {value: 'nl', label: 'Nederlands'},
{value: 'tl', label: 'Filipino'}, {value: 'tl', label: 'Filipino'},
{value: 'fr', label: 'Français'}, {value: 'fr', label: 'Français'},
+76 -47
View File
@@ -74,16 +74,17 @@ const BORDER_RADIUS = 20
const SQUARED_BORDER_RADIUS = 4 const SQUARED_BORDER_RADIUS = 4
const DISPLAY_NAME_INSET = 20 const DISPLAY_NAME_INSET = 20
function messageIsReply( export type MessageItemNeighbor =
message:
| ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView | ChatBskyConvoDefs.DeletedMessageView
| null, | null
): boolean {
function messageIsReply(message: MessageItemNeighbor): boolean {
return ( return (
ChatBskyConvoDefs.isMessageView(message) && ChatBskyConvoDefs.isMessageView(message) &&
(ChatBskyConvoDefs.isMessageView(message.replyTo) || (ChatBskyConvoDefs.isMessageView(message.replyTo) ||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo)) ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) ||
ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(message.replyTo))
) )
} }
@@ -96,10 +97,7 @@ function isWithinClusterBoundary({
}: { }: {
isPending: boolean isPending: boolean
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
adjacentMessage: adjacentMessage: MessageItemNeighbor
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
isFromSameSender: boolean isFromSameSender: boolean
direction: 'prev' | 'next' direction: 'prev' | 'next'
}): boolean { }): boolean {
@@ -135,14 +133,8 @@ let MessageItem = ({
}: { }: {
item: ConvoItem & {type: 'message' | 'pending-message'} item: ConvoItem & {type: 'message' | 'pending-message'}
isGroupChat?: boolean isGroupChat?: boolean
prevMessage: prevMessage: MessageItemNeighbor
| ChatBskyConvoDefs.MessageView nextMessage: MessageItemNeighbor
| ChatBskyConvoDefs.DeletedMessageView
| null
nextMessage:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| null
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
@@ -157,14 +149,22 @@ let MessageItem = ({
const {openReactions} = useMessageDialogs() const {openReactions} = useMessageDialogs()
const {scrollToMessage, highlightedMessage} = useMessageReplies() const {scrollToMessage, highlightedMessage} = useMessageReplies()
// `replyTo` comes back hydrated as the referenced message (or a deleted- // `replyTo` comes back hydrated as the referenced message, a deleted-message
// message tombstone). Narrow away the open-union fallback so we only render // tombstone, or a before-joined placeholder. Narrow away the open-union
// shapes we understand. // fallback so we only render shapes we understand.
const replyTo = const replyTo =
ChatBskyConvoDefs.isMessageView(message.replyTo) || ChatBskyConvoDefs.isMessageView(message.replyTo) ||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) ||
ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(message.replyTo)
? message.replyTo ? message.replyTo
: undefined : undefined
const replyToMessageId =
replyTo && !ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(replyTo)
? replyTo.id
: undefined
const onPressReplyTo = replyToMessageId
? () => scrollToMessage(replyToMessageId)
: undefined
const isPending = item.type === 'pending-message' const isPending = item.type === 'pending-message'
@@ -468,7 +468,7 @@ let MessageItem = ({
isGroupChat={isGroupChat} isGroupChat={isGroupChat}
replierDisplayName={displayName} replierDisplayName={displayName}
relatedProfiles={relatedProfiles} relatedProfiles={relatedProfiles}
onPress={() => scrollToMessage(replyTo.id)} onPress={onPressReplyTo}
/> />
) : displayName && showDisplayName ? ( ) : displayName && showDisplayName ? (
<Text <Text
@@ -541,7 +541,7 @@ let MessageItem = ({
replyTo={replyTo} replyTo={replyTo}
isFromSelf={isFromSelf} isFromSelf={isFromSelf}
relatedProfiles={relatedProfiles} relatedProfiles={relatedProfiles}
onPress={() => scrollToMessage(replyTo.id)} onPress={onPressReplyTo}
/> />
) : null} ) : null}
<RichText <RichText
@@ -751,17 +751,25 @@ function ReplyCaption({
relatedProfiles, relatedProfiles,
onPress, onPress,
}: { }: {
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView replyTo:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| ChatBskyConvoDefs.MessageBeforeUserJoinedGroupView
isFromSelf: boolean isFromSelf: boolean
isGroupChat: boolean isGroupChat: boolean
replierDisplayName: string | null replierDisplayName: string | null
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
onPress: () => void onPress?: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
let caption: string = ''
if (
ChatBskyConvoDefs.isMessageView(replyTo) ||
ChatBskyConvoDefs.isDeletedMessageView(replyTo)
) {
const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did
const originalProfile = relatedProfiles.get(replyTo.sender.did) const originalProfile = relatedProfiles.get(replyTo.sender.did)
const originalName = originalSenderIsSelf const originalName = originalSenderIsSelf
@@ -770,9 +778,29 @@ function ReplyCaption({
? createSanitizedDisplayName(originalProfile) ? createSanitizedDisplayName(originalProfile)
: null : null
caption = isFromSelf
? originalSenderIsSelf
? l`You replied to yourself`
: originalName
? l`You replied to ${originalName}`
: l`You replied`
: originalSenderIsSelf
? l`${replierDisplayName} replied to you`
: originalName
? l`${replierDisplayName} replied to ${originalName}`
: l`${replierDisplayName} replied`
} else {
caption = l`Someone replied`
}
return ( return (
<Button <Button
label={l`Scroll to the message this is replying to`} label={
onPress
? l`Scroll to the message this is replying to`
: l`A reply to a message sent before you joined`
}
disabled={!onPress}
onPress={onPress} onPress={onPress}
style={[ style={[
a.w_full, a.w_full,
@@ -796,23 +824,7 @@ function ReplyCaption({
style={[a.text_xs, a.flex_shrink, t.atoms.text_contrast_medium]} style={[a.text_xs, a.flex_shrink, t.atoms.text_contrast_medium]}
numberOfLines={1} numberOfLines={1}
emoji> emoji>
{isFromSelf ? ( {caption}
originalSenderIsSelf ? (
<Trans>You replied to yourself</Trans>
) : originalName ? (
<Trans>You replied to {originalName}</Trans>
) : (
<Trans>You replied</Trans>
)
) : originalSenderIsSelf ? (
<Trans>{replierDisplayName} replied to you</Trans>
) : originalName ? (
<Trans>
{replierDisplayName} replied to {originalName}
</Trans>
) : (
<Trans>{replierDisplayName} replied</Trans>
)}
</Text> </Text>
</Button> </Button>
) )
@@ -828,17 +840,25 @@ function ReplyQuote({
relatedProfiles, relatedProfiles,
onPress, onPress,
}: { }: {
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView replyTo:
| ChatBskyConvoDefs.MessageView
| ChatBskyConvoDefs.DeletedMessageView
| ChatBskyConvoDefs.MessageBeforeUserJoinedGroupView
isFromSelf: boolean isFromSelf: boolean
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
onPress: () => void onPress?: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const getReplyPreviewText = useReplyPreviewText() const getReplyPreviewText = useReplyPreviewText()
const senderDid =
ChatBskyConvoDefs.isMessageView(replyTo) ||
ChatBskyConvoDefs.isDeletedMessageView(replyTo)
? replyTo.sender.did
: undefined
const senderProfile = useMaybeProfileShadow( const senderProfile = useMaybeProfileShadow(
relatedProfiles.get(replyTo.sender.did), senderDid ? relatedProfiles.get(senderDid) : undefined,
) )
// Hide the quoted content if we block, or are blocked by, the original // Hide the quoted content if we block, or are blocked by, the original
// sender - mirroring how the message bubble itself is hidden. // sender - mirroring how the message bubble itself is hidden.
@@ -866,6 +886,12 @@ function ReplyQuote({
subtle = true subtle = true
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) { } else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
;({text, subtle} = getReplyPreviewText(replyTo)) ;({text, subtle} = getReplyPreviewText(replyTo))
} else if (ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(replyTo)) {
text = l({
message: `(message sent before you joined)`,
comment: 'A reply summary in chat',
})
subtle = true
} else { } else {
text = l({message: '(deleted message)', comment: 'A reply summary in chat'}) text = l({message: '(deleted message)', comment: 'A reply summary in chat'})
subtle = true subtle = true
@@ -874,10 +900,13 @@ function ReplyQuote({
return ( return (
<Button <Button
label={ label={
senderName !onPress
? l`Replied-to message was sent before you joined`
: senderName
? l`Replied-to message from ${senderName}, tap to scroll to it` ? l`Replied-to message from ${senderName}, tap to scroll to it`
: l`Replied-to message, tap to scroll to it` : l`Replied-to message, tap to scroll to it`
} }
disabled={!onPress}
onPress={onPress} onPress={onPress}
style={[ style={[
a.mb_xs, a.mb_xs,
@@ -23,6 +23,7 @@ export function DateField({
testID, testID,
accessibilityHint, accessibilityHint,
maximumDate, maximumDate,
minimumDate,
}: DateFieldProps) { }: DateFieldProps) {
const {i18n} = useLingui() const {i18n} = useLingui()
const t = useTheme() const t = useTheme()
@@ -107,6 +108,9 @@ export function DateField({
maximumDate={ maximumDate={
maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined
} }
minimumDate={
minimumDate ? new Date(toSimpleDateString(minimumDate)) : undefined
}
/> />
)} )}
</> </>
+6
View File
@@ -35,6 +35,7 @@ export function DateField({
isInvalid, isInvalid,
accessibilityHint, accessibilityHint,
maximumDate, maximumDate,
minimumDate,
}: DateFieldProps) { }: DateFieldProps) {
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const t = useTheme() const t = useTheme()
@@ -116,6 +117,11 @@ export function DateField({
? new Date(toSimpleDateString(maximumDate)) ? new Date(toSimpleDateString(maximumDate))
: undefined : undefined
} }
minimumDate={
minimumDate
? new Date(toSimpleDateString(minimumDate))
: undefined
}
/> />
</View> </View>
<Button <Button
@@ -42,6 +42,7 @@ export function DateField({
testID, testID,
accessibilityHint, accessibilityHint,
maximumDate, maximumDate,
minimumDate,
}: DateFieldProps) { }: DateFieldProps) {
const handleOnChange = useCallback( const handleOnChange = useCallback(
(e: any) => { (e: any) => {
@@ -68,6 +69,7 @@ export function DateField({
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
// @ts-expect-error not typed as <input type="date"> even though it is one // @ts-expect-error not typed as <input type="date"> even though it is one
max={maximumDate ? toSimpleDateString(maximumDate) : undefined} max={maximumDate ? toSimpleDateString(maximumDate) : undefined}
min={minimumDate ? toSimpleDateString(minimumDate) : undefined}
/> />
</TextField.Root> </TextField.Root>
) )
+1
View File
@@ -26,4 +26,5 @@ export type DateFieldProps = {
testID?: string testID?: string
accessibilityHint?: string accessibilityHint?: string
maximumDate?: string | Date maximumDate?: string | Date
minimumDate?: string | Date
} }
-46
View File
@@ -1,46 +0,0 @@
import {Children, cloneElement, Fragment, isValidElement} from 'react'
import {View} from 'react-native'
import {atoms, useTheme} from '#/alf'
/**
* NOT FINISHED, just here as a reference
*/
export function InputGroup(props: React.PropsWithChildren<{}>) {
const t = useTheme()
const children = Children.toArray(props.children)
const total = children.length
return (
<View style={[atoms.w_full]}>
{children.map((child, i) => {
return isValidElement(child) ? (
<Fragment key={i}>
{i > 0 ? (
<View
style={[atoms.border_b, {borderColor: t.palette.contrast_500}]}
/>
) : null}
{cloneElement(child, {
// @ts-ignore
style: [
// @ts-ignore
...(Array.isArray(child.props?.style)
? // @ts-ignore
child.props.style
: // @ts-ignore
[child.props.style || {}]),
{
borderTopLeftRadius: i > 0 ? 0 : undefined,
borderTopRightRadius: i > 0 ? 0 : undefined,
borderBottomLeftRadius: i < total - 1 ? 0 : undefined,
borderBottomRightRadius: i < total - 1 ? 0 : undefined,
borderBottomWidth: i < total - 1 ? 0 : undefined,
},
],
})}
</Fragment>
) : null
})}
</View>
)
}
@@ -1,170 +0,0 @@
import {View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useCleanError} from '#/lib/hooks/useCleanError'
import {atoms as a, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Span, Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {useUpdateLiveEventPreferences} from '#/features/liveEvents/preferences'
import {
type LiveEventFeed,
type LiveEventFeedMetricContext,
} from '#/features/liveEvents/types'
export {useDialogControl} from '#/components/Dialog'
export function LiveEventFeedOptionsMenu({
control,
feed,
metricContext,
}: {
control: Dialog.DialogControlProps
feed: LiveEventFeed
metricContext: LiveEventFeedMetricContext
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Configure live event banner`)}
style={[web({maxWidth: 400})]}>
<Inner control={control} feed={feed} metricContext={metricContext} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function Inner({
control,
feed,
metricContext,
}: {
control: Dialog.DialogControlProps
feed: LiveEventFeed
metricContext: LiveEventFeedMetricContext
}) {
const {_} = useLingui()
const {
isPending,
mutate: update,
error: rawError,
variables,
} = useUpdateLiveEventPreferences({
feed,
metricContext,
onUpdateSuccess({undoAction}) {
Toast.show(
<Toast.Outer>
<Toast.Icon />
<Toast.Text>
<Trans>Your live event preferences have been updated.</Trans>
</Toast.Text>
{undoAction && (
<Toast.Action
label={_(msg`Undo`)}
onPress={() => {
if (undoAction) {
update(undoAction)
}
}}>
<Trans>Undo</Trans>
</Toast.Action>
)}
</Toast.Outer>,
{type: 'success'},
)
/*
* If there is no `undoAction`, it means that the action was already
* undone, and therefore the menu would have been closed prior to the
* undo happening.
*/
if (undoAction) {
control.close()
}
},
})
const cleanError = useCleanError()
const error = rawError ? cleanError(rawError) : undefined
const isHidingFeed = variables?.type === 'hideFeed' && isPending
const isHidingAllFeeds = variables?.type === 'toggleHideAllFeeds' && isPending
return (
<View style={[a.gap_lg]}>
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_semi_bold, a.leading_snug]}>
<Trans>Live event options</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Live events appear occasionally when something exciting is
happening. If you'd like, you can hide this particular event, or all
events for this placement in your app interface.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
If you choose to hide all events, you can always re-enable them from{' '}
<Span style={[a.font_semi_bold]}>Settings → Content & Media</Span>.
</Trans>
</Text>
</View>
<View style={[a.gap_sm]}>
<Button
label={_(msg`Hide this event`)}
size="large"
color="primary_subtle"
onPress={() => {
update({type: 'hideFeed', id: feed.id})
}}>
<ButtonText>
<Trans>Hide this event</Trans>
</ButtonText>
{isHidingFeed && <ButtonIcon icon={Loader} />}
</Button>
<Button
label={_(msg`Hide all events`)}
size="large"
color="secondary"
onPress={() => {
update({type: 'toggleHideAllFeeds'})
}}>
<ButtonText>
<Trans>Hide all events</Trans>
</ButtonText>
{isHidingAllFeeds && <ButtonIcon icon={Loader} />}
</Button>
{IS_NATIVE && (
<Button
label={_(msg`Cancel`)}
size="large"
color="secondary_inverted"
onPress={() => control.close()}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
)}
</View>
{error && (
<Admonition type="error">
{error.clean || error.raw || _(msg`An unknown error occurred.`)}
</Admonition>
)}
</View>
)
}
@@ -1,44 +0,0 @@
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import * as Toggle from '#/components/forms/Toggle'
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
import {
useLiveEventPreferences,
useUpdateLiveEventPreferences,
} from '#/features/liveEvents/preferences'
export function LiveEventFeedsSettingsToggle() {
const {_} = useLingui()
const {data: prefs} = useLiveEventPreferences()
const {
isPending,
data: updatedPrefs,
mutate: update,
} = useUpdateLiveEventPreferences({
metricContext: 'settings',
})
const hideAllFeeds = !!(updatedPrefs || prefs)?.hideAllFeeds
return (
<Toggle.Item
name="enable_live_event_banner"
label={_(msg`Show live events in your Discover Feed`)}
value={!hideAllFeeds}
onChange={() => {
if (!isPending) {
update({type: 'toggleHideAllFeeds'})
}
}}>
<SettingsList.Item>
<SettingsList.ItemIcon icon={LiveIcon} />
<SettingsList.ItemText>
<Trans>Show live events in your Discover Feed</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
)
}
-302
View File
@@ -1,302 +0,0 @@
import {useMemo} from 'react'
import {deviceLocales} from '#/locale/deviceLocales'
import {useLanguagePrefs} from '#/state/preferences'
import {useGeolocation} from '#/geolocation'
/**
* From react-native-localize
*
* MIT License
* Copyright (c) 2017-present, Mathieu Acthernoene
*
* @see https://github.com/zoontek/react-native-localize/blob/master/LICENSE
* @see https://github.com/zoontek/react-native-localize/blob/ee5bf25e0bb8f3b8e4f3fd055f67ad46269c81ea/src/constants.ts
*/
export const countryCodeToCurrency: Record<string, string> = {
ad: 'eur',
ae: 'aed',
af: 'afn',
ag: 'xcd',
ai: 'xcd',
al: 'all',
am: 'amd',
an: 'ang',
ao: 'aoa',
ar: 'ars',
as: 'usd',
at: 'eur',
au: 'aud',
aw: 'awg',
ax: 'eur',
az: 'azn',
ba: 'bam',
bb: 'bbd',
bd: 'bdt',
be: 'eur',
bf: 'xof',
bg: 'bgn',
bh: 'bhd',
bi: 'bif',
bj: 'xof',
bl: 'eur',
bm: 'bmd',
bn: 'bnd',
bo: 'bob',
bq: 'usd',
br: 'brl',
bs: 'bsd',
bt: 'btn',
bv: 'nok',
bw: 'bwp',
by: 'byn',
bz: 'bzd',
ca: 'cad',
cc: 'aud',
cd: 'cdf',
cf: 'xaf',
cg: 'xaf',
ch: 'chf',
ci: 'xof',
ck: 'nzd',
cl: 'clp',
cm: 'xaf',
cn: 'cny',
co: 'cop',
cr: 'crc',
cu: 'cup',
cv: 'cve',
cw: 'ang',
cx: 'aud',
cy: 'eur',
cz: 'czk',
de: 'eur',
dj: 'djf',
dk: 'dkk',
dm: 'xcd',
do: 'dop',
dz: 'dzd',
ec: 'usd',
ee: 'eur',
eg: 'egp',
eh: 'mad',
er: 'ern',
es: 'eur',
et: 'etb',
fi: 'eur',
fj: 'fjd',
fk: 'fkp',
fm: 'usd',
fo: 'dkk',
fr: 'eur',
ga: 'xaf',
gb: 'gbp',
gd: 'xcd',
ge: 'gel',
gf: 'eur',
gg: 'gbp',
gh: 'ghs',
gi: 'gip',
gl: 'dkk',
gm: 'gmd',
gn: 'gnf',
gp: 'eur',
gq: 'xaf',
gr: 'eur',
gs: 'gbp',
gt: 'gtq',
gu: 'usd',
gw: 'xof',
gy: 'gyd',
hk: 'hkd',
hm: 'aud',
hn: 'hnl',
hr: 'hrk',
ht: 'htg',
hu: 'huf',
id: 'idr',
ie: 'eur',
il: 'ils',
im: 'gbp',
in: 'inr',
io: 'usd',
iq: 'iqd',
ir: 'irr',
is: 'isk',
it: 'eur',
je: 'gbp',
jm: 'jmd',
jo: 'jod',
jp: 'jpy',
ke: 'kes',
kg: 'kgs',
kh: 'khr',
ki: 'aud',
km: 'kmf',
kn: 'xcd',
kp: 'kpw',
kr: 'krw',
kw: 'kwd',
ky: 'kyd',
kz: 'kzt',
la: 'lak',
lb: 'lbp',
lc: 'xcd',
li: 'chf',
lk: 'lkr',
lr: 'lrd',
ls: 'lsl',
lt: 'eur',
lu: 'eur',
lv: 'eur',
ly: 'lyd',
ma: 'mad',
mc: 'eur',
md: 'mdl',
me: 'eur',
mf: 'eur',
mg: 'mga',
mh: 'usd',
mk: 'mkd',
ml: 'xof',
mm: 'mmk',
mn: 'mnt',
mo: 'mop',
mp: 'usd',
mq: 'eur',
mr: 'mro',
ms: 'xcd',
mt: 'eur',
mu: 'mur',
mv: 'mvr',
mw: 'mwk',
mx: 'mxn',
my: 'myr',
mz: 'mzn',
na: 'nad',
nc: 'xpf',
ne: 'xof',
nf: 'aud',
ng: 'ngn',
ni: 'nio',
nl: 'eur',
no: 'nok',
np: 'npr',
nr: 'aud',
nu: 'nzd',
nz: 'nzd',
om: 'omr',
pa: 'pab',
pe: 'pen',
pf: 'xpf',
pg: 'pgk',
ph: 'php',
pk: 'pkr',
pl: 'pln',
pm: 'eur',
pn: 'nzd',
pr: 'usd',
ps: 'ils',
pt: 'eur',
pw: 'usd',
py: 'pyg',
qa: 'qar',
re: 'eur',
ro: 'ron',
rs: 'rsd',
ru: 'rub',
rw: 'rwf',
sa: 'sar',
sb: 'sbd',
sc: 'scr',
sd: 'sdg',
se: 'sek',
sg: 'sgd',
sh: 'shp',
si: 'eur',
sj: 'nok',
sk: 'eur',
sl: 'sll',
sm: 'eur',
sn: 'xof',
so: 'sos',
sr: 'srd',
ss: 'ssp',
st: 'std',
sv: 'svc',
sx: 'ang',
sy: 'syp',
sz: 'szl',
tc: 'usd',
td: 'xaf',
tf: 'eur',
tg: 'xof',
th: 'thb',
tj: 'tjs',
tk: 'nzd',
tl: 'usd',
tm: 'tmt',
tn: 'tnd',
to: 'top',
tr: 'try',
tt: 'ttd',
tv: 'aud',
tw: 'twd',
tz: 'tzs',
ua: 'uah',
ug: 'ugx',
um: 'usd',
us: 'usd',
uy: 'uyu',
uz: 'uzs',
va: 'eur',
vc: 'xcd',
ve: 'vef',
vg: 'usd',
vi: 'usd',
vn: 'vnd',
vu: 'vuv',
wf: 'xpf',
ws: 'wst',
ye: 'yer',
yt: 'eur',
za: 'zar',
zm: 'zmw',
zw: 'zwl',
}
/**
* Best-guess currency formatting.
*
* Attempts to use `getLocales` from `expo-localization` if available,
* otherwise falls back to the `persisted.appLanguage` setting, and geolocation
* API for region.
*/
export function useFormatCurrency(
options?: Parameters<typeof Intl.NumberFormat>[1],
) {
const geolocation = useGeolocation()
const {appLanguage} = useLanguagePrefs()
return useMemo(() => {
const locale = deviceLocales.at(0)
const languageTag = locale?.languageTag || appLanguage || 'en-US'
const countryCode = (
locale?.regionCode ||
geolocation?.countryCode ||
'us'
).toLowerCase()
const currency = countryCodeToCurrency[countryCode] || 'usd'
const format = new Intl.NumberFormat(languageTag, {
...(options || {}),
style: 'currency',
currency: currency,
}).format
return {
format,
currency,
countryCode,
languageTag,
}
}, [geolocation, appLanguage, options])
}
-27
View File
@@ -1,27 +0,0 @@
import {withDelay, withSequence, withTiming} from 'react-native-reanimated'
export function ShrinkAndPop() {
'worklet'
const animations = {
opacity: withDelay(125, withTiming(0, {duration: 125})),
transform: [
{
scale: withSequence(
withTiming(0.7, {duration: 75}),
withTiming(1.1, {duration: 150}),
),
},
],
}
const initialValues = {
opacity: 1,
transform: [{scale: 1}],
}
return {
animations,
initialValues,
}
}
-28
View File
@@ -1,28 +0,0 @@
import {useEffect, useState} from 'react'
import {useNavigation} from '@react-navigation/native'
import {getTabState, TabState} from '#/lib/routes/helpers'
export function useTabFocusEffect(
tabName: string,
cb: (isInside: boolean) => void,
) {
const [isInside, setIsInside] = useState(false)
// get root navigator state
let nav = useNavigation()
while (nav.getParent()) {
nav = nav.getParent()
}
const state = nav.getState()
useEffect(() => {
// check if inside
let v = getTabState(state, tabName) !== TabState.Outside
if (v !== isInside) {
// fire
setIsInside(v)
cb(v)
}
}, [state, isInside, setIsInside, tabName, cb])
}
-32
View File
@@ -1,32 +0,0 @@
import {useCallback, useEffect, useRef} from 'react'
/**
* Helper hook to run persistent timers on views
*/
export function useTimer(time: number, handler: () => void) {
const timer = useRef<undefined | NodeJS.Timeout>(undefined)
// function to restart the timer
const reset = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
}
timer.current = setTimeout(handler, time)
}, [time, timer, handler])
// function to cancel the timer
const cancel = useCallback(() => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = undefined
}
}, [timer])
// start the timer immediately
useEffect(() => {
reset()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return [reset, cancel]
}
View File
+15 -2
View File
@@ -2,7 +2,9 @@ import {getVideoMetaData, Video} from 'react-native-compressor'
import {type ImagePickerAsset} from 'expo-image-picker' import {type ImagePickerAsset} from 'expo-image-picker'
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants' import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
import {type CompressedVideo} from './types' import {logger} from '#/logger'
import {probe} from '../../../../modules/expo-bluesky-video-compress'
import {type CompressedVideo, type ProbedMetadata} from './types'
import {extToMime} from './util' import {extToMime} from './util'
const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
@@ -12,9 +14,20 @@ export async function compressVideo(
opts?: { opts?: {
signal?: AbortSignal signal?: AbortSignal
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
onProbe?: (metadata: ProbedMetadata) => void
}, },
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {} const {onProgress, signal, onProbe} = opts || {}
// Probe data is purely informational - fired into telemetry to validate
// future smart-skip thresholds. Failures must not block the upload.
if (onProbe && file.mimeType !== 'image/gif') {
try {
onProbe(await probe(file.uri))
} catch (e) {
logger.debug('video probe failed', {safeMessage: e})
}
}
if (file.mimeType === 'image/gif') { if (file.mimeType === 'image/gif') {
// let's hope they're small enough that they don't need compression! // let's hope they're small enough that they don't need compression!
+53 -2
View File
@@ -23,7 +23,7 @@ import {
COMPRESSION_MIN_SIZE_BYTES, COMPRESSION_MIN_SIZE_BYTES,
COMPRESSION_TARGET_BITRATE, COMPRESSION_TARGET_BITRATE,
} from './constants' } from './constants'
import {type CompressedVideo} from './types' import {type CompressedVideo, type ProbedMetadata} from './types'
// Codecs to try in order of preference // Codecs to try in order of preference
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM // avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
@@ -34,9 +34,10 @@ export async function compressVideo(
opts?: { opts?: {
signal?: AbortSignal signal?: AbortSignal
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
onProbe?: (metadata: ProbedMetadata) => void
}, },
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {} const {onProgress, signal, onProbe} = opts || {}
logger.debug('compress: starting', { logger.debug('compress: starting', {
uri: asset.uri.slice(0, 50), uri: asset.uri.slice(0, 50),
@@ -49,6 +50,18 @@ export async function compressVideo(
const isGif = blob.type === 'image/gif' const isGif = blob.type === 'image/gif'
const hasCodecs = hasWebCodecs() const hasCodecs = hasWebCodecs()
// Probe pass: extract source metadata via mediabunny before any compression
// decision. Fires before doCompression so the probed event lands ahead of
// compressCompleted/compressSkipped in the funnel. GIFs and missing
// WebCodecs both skip - mediabunny needs a parseable container + decoder.
if (onProbe && !isGif && hasCodecs) {
try {
onProbe(await probeWithMediaBunny(blob))
} catch (e) {
logger.debug('video probe failed', {safeMessage: e})
}
}
logger.debug('compress: fetched blob', { logger.debug('compress: fetched blob', {
size: blob.size, size: blob.size,
mimeType: blob.type, mimeType: blob.type,
@@ -98,6 +111,44 @@ export async function compressVideo(
} }
} }
async function probeWithMediaBunny(blob: Blob): Promise<ProbedMetadata> {
const input = new Input({source: new BlobSource(blob), formats: ALL_FORMATS})
try {
const videoTrack = await input.getPrimaryVideoTrack()
if (!videoTrack) {
throw new Error('No video track found')
}
const audioTrack = await input.getPrimaryAudioTrack()
const [codec, codedWidth, codedHeight, rotation, isHDR, stats, duration] =
await Promise.all([
videoTrack.getCodec(),
videoTrack.getCodedWidth(),
videoTrack.getCodedHeight(),
videoTrack.getRotation(),
videoTrack.hasHighDynamicRange(),
// Sample a small fixed slice instead of scanning the whole file - we
// only want an approximate bitrate / frame rate for telemetry.
videoTrack.computePacketStats(100),
input.computeDuration(),
])
return {
mimeType: blob.type || 'video/mp4',
codec: codec ?? 'unknown',
width: codedWidth,
height: codedHeight,
duration,
bitrate: Math.round(stats.averageBitrate),
fileSize: blob.size,
hasAudio: audioTrack !== null,
frameRate: stats.averagePacketRate,
rotation,
isHDR,
}
} finally {
input.dispose()
}
}
async function findEncodableVideoCodec( async function findEncodableVideoCodec(
width: number, width: number,
height: number, height: number,
+4 -2
View File
@@ -1,8 +1,10 @@
// Shared compression knobs. Mirrored between native (compress.ts) and web // Shared compression knobs. Mirrored between native (compress.ts) and web
// (compress.web.ts) so both platforms produce videos with the same target. // (compress.web.ts) so both platforms produce videos with the same target.
// Target encode bitrate when we do compress.
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps
// Output dimension cap when compressing, and skip threshold for source files.
export const COMPRESSION_MAX_DIMENSION = 1920 export const COMPRESSION_MAX_DIMENSION = 1920
// Web skips compression entirely for files under this size; native applies its // Web only: files under this size skip compression entirely. Native applies
// own threshold logic inside expo-bluesky-video-compress's probe step. // its own threshold logic inside react-native-compressor.
export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000 export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000
+23 -1
View File
@@ -2,7 +2,10 @@ import {Platform} from 'react-native'
import {type ImagePickerAsset} from 'expo-image-picker' import {type ImagePickerAsset} from 'expo-image-picker'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {type VideoCompressSkipReason} from '#/lib/media/video/types' import {
type ProbedMetadata,
type VideoCompressSkipReason,
} from '#/lib/media/video/types'
import {Sentry} from '#/logger/sentry/lib' import {Sentry} from '#/logger/sentry/lib'
import {type Metrics} from '#/analytics/metrics' import {type Metrics} from '#/analytics/metrics'
@@ -28,6 +31,7 @@ export type VideoTelemetry = {
readonly engine: string readonly engine: string
picked: () => void picked: () => void
compressStarted: () => void compressStarted: () => void
probed: (metadata: ProbedMetadata) => void
compressSkipped: (video: { compressSkipped: (video: {
size: number size: number
mimeType: string mimeType: string
@@ -155,6 +159,24 @@ export function createVideoTelemetry({
}) })
}, },
probed(metadata) {
metric('video:upload:probed', {
uploadId,
engine,
mimeType: metadata.mimeType,
codec: metadata.codec,
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
bitrate: metadata.bitrate,
fileSize: metadata.fileSize,
hasAudio: metadata.hasAudio,
frameRate: metadata.frameRate,
rotation: metadata.rotation,
isHDR: metadata.isHDR,
})
},
compressSkipped({size, mimeType, skipReason}) { compressSkipped({size, mimeType, skipReason}) {
metric('video:upload:compressSkipped', { metric('video:upload:compressSkipped', {
uploadId, uploadId,
+17
View File
@@ -18,3 +18,20 @@ export type CompressedVideo = {
// bytes were actually re-encoded. // bytes were actually re-encoded.
passthroughReason?: VideoCompressSkipReason passthroughReason?: VideoCompressSkipReason
} }
// Source container metadata read off the input before any encoding decision.
// Same shape across native (expo-bluesky-video-compress probe) and web
// (mediabunny Input + track inspection). Numbers are raw - no bucketing.
export type ProbedMetadata = {
mimeType: string
codec: string
width: number
height: number
duration: number
bitrate: number
fileSize: number
hasAudio: boolean
frameRate: number
rotation: number
isHDR: boolean
}
+20 -1
View File
@@ -249,7 +249,26 @@ export function useRequestNotificationsPermission() {
return return
} }
const res = await Notifications.requestPermissionsAsync() const res = await Notifications.requestPermissionsAsync({
ios: {
/*
* These three default to true when no argument is passed to
* `requestPermissionsAsync`, but passing an options object opts out of
* that default, so we have to set them explicitly to preserve the
* existing behavior.
*/
allowAlert: true,
allowBadge: true,
allowSound: true,
/*
* Adds an in-app notification settings button to the system Settings
* screen for Bluesky. When tapped, iOS calls back into the app, which
* we route to the in-app notification settings (see the
* NotificationSettings module in expo-bluesky-swiss-army).
*/
provideAppNotificationSettings: true,
},
})
ax.metric(`notifications:request`, { ax.metric(`notifications:request`, {
context: context, context: context,
+19 -5
View File
@@ -1,10 +1,24 @@
import {type NavigationState, type PartialState} from '@react-navigation/native' import {type NavigationState, type PartialState} from '@react-navigation/native'
import {type NativeStackNavigationProp} from '@react-navigation/native-stack' import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
import {type SearchFilters} from '#/screens/Search/searchParams'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
export type {NativeStackScreenProps} from '@react-navigation/native-stack' export type {NativeStackScreenProps} from '@react-navigation/native-stack'
/**
* The advanced-search filter params are owned by searchParams.ts (the param
* model, serialization, and helpers all live there). Re-export the type so the
* route params stay in sync with it automatically rather than being a second
* hand-maintained copy.
*/
export type SearchFilterParams = SearchFilters
export type SearchParams = {
q?: string
tab?: 'user' | 'profile' | 'feed' | 'latest'
} & SearchFilterParams
export type CommonNavigatorParams = { export type CommonNavigatorParams = {
NotFound: undefined NotFound: undefined
Lists: undefined Lists: undefined
@@ -19,7 +33,7 @@ export type CommonNavigatorParams = {
ProfileFollowers: {name: string} ProfileFollowers: {name: string}
ProfileFollows: {name: string} ProfileFollows: {name: string}
ProfileKnownFollowers: {name: string} ProfileKnownFollowers: {name: string}
ProfileSearch: {name: string; q?: string} ProfileSearch: {name: string} & SearchParams
ProfileList: {name: string; rkey: string} ProfileList: {name: string; rkey: string}
PostThread: {name: string; rkey: string} PostThread: {name: string; rkey: string}
PostLikedBy: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string}
@@ -60,7 +74,7 @@ export type CommonNavigatorParams = {
AppIconSettings: undefined AppIconSettings: undefined
FindContactsSettings: undefined FindContactsSettings: undefined
InviteScanner: undefined InviteScanner: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} Search: SearchParams
Hashtag: {tag: string; author?: string} Hashtag: {tag: string; author?: string}
Topic: {topic: string} Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true} MessagesConversation: {conversation: string; embed?: string; accept?: true}
@@ -98,7 +112,7 @@ export type HomeTabNavigatorParams = CommonNavigatorParams & {
} }
export type SearchTabNavigatorParams = CommonNavigatorParams & { export type SearchTabNavigatorParams = CommonNavigatorParams & {
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} Search: SearchParams
} }
export type NotificationsTabNavigatorParams = CommonNavigatorParams & { export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
@@ -119,7 +133,7 @@ export type MessagesTabNavigatorParams = CommonNavigatorParams & {
export type FlatNavigatorParams = CommonNavigatorParams & { export type FlatNavigatorParams = CommonNavigatorParams & {
Home: undefined Home: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} Search: SearchParams
Feeds: undefined Feeds: undefined
Notifications: undefined Notifications: undefined
Messages: { Messages: {
@@ -133,7 +147,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
HomeTab: undefined HomeTab: undefined
Home: undefined Home: undefined
SearchTab: undefined SearchTab: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'} Search: SearchParams
Feeds: undefined Feeds: undefined
NotificationsTab: undefined NotificationsTab: undefined
Notifications: undefined Notifications: undefined
+2
View File
@@ -169,6 +169,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ast return AppLanguage.ast
case 'ca': case 'ca':
return AppLanguage.ca return AppLanguage.ca
case 'cs':
return AppLanguage.cs
case 'cy': case 'cy':
return AppLanguage.cy return AppLanguage.cy
case 'da': case 'da':
+11
View File
@@ -17,6 +17,7 @@ import {AppLanguage} from '#/locale/languages'
import {messages as messagesAn} from '#/locale/locales/an/messages' import {messages as messagesAn} from '#/locale/locales/an/messages'
import {messages as messagesAst} from '#/locale/locales/ast/messages' import {messages as messagesAst} from '#/locale/locales/ast/messages'
import {messages as messagesCa} from '#/locale/locales/ca/messages' import {messages as messagesCa} from '#/locale/locales/ca/messages'
import {messages as messagesCs} from '#/locale/locales/cs/messages'
import {messages as messagesCy} from '#/locale/locales/cy/messages' import {messages as messagesCy} from '#/locale/locales/cy/messages'
import {messages as messagesDa} from '#/locale/locales/da/messages' import {messages as messagesDa} from '#/locale/locales/da/messages'
import {messages as messagesDe} from '#/locale/locales/de/messages' import {messages as messagesDe} from '#/locale/locales/de/messages'
@@ -92,6 +93,16 @@ export async function dynamicActivate(locale: AppLanguage) {
]) ])
return dateLocale return dateLocale
} }
case AppLanguage.cs: {
i18n.loadAndActivate({locale, messages: messagesCs})
const [dateLocale] = await Promise.all([
import('date-fns/locale/cs').then(m => m.cs),
import('@formatjs/intl-pluralrules/locale-data/cs.js'),
import('@formatjs/intl-numberformat/locale-data/cs.js'),
import('@formatjs/intl-displaynames/locale-data/cs.js'),
])
return dateLocale
}
case AppLanguage.cy: { case AppLanguage.cy: {
i18n.loadAndActivate({locale, messages: messagesCy}) i18n.loadAndActivate({locale, messages: messagesCy})
const [dateLocale] = await Promise.all([ const [dateLocale] = await Promise.all([
+7
View File
@@ -36,6 +36,13 @@ export async function dynamicActivate(locale: AppLanguage) {
]) ])
break break
} }
case AppLanguage.cs: {
;[messages, dateLocale] = await Promise.all([
import(`./locales/cs/messages`).then(m => m.messages),
import('date-fns/locale/cs').then(m => m.cs),
])
break
}
case AppLanguage.cy: { case AppLanguage.cy: {
;[messages, dateLocale] = await Promise.all([ ;[messages, dateLocale] = await Promise.all([
import(`./locales/cy/messages`).then(m => m.messages), import(`./locales/cy/messages`).then(m => m.messages),
+2
View File
@@ -9,6 +9,7 @@ export enum AppLanguage {
an = 'an', an = 'an',
ast = 'ast', ast = 'ast',
ca = 'ca', ca = 'ca',
cs = 'cs',
cy = 'cy', cy = 'cy',
da = 'da', da = 'da',
de = 'de', de = 'de',
@@ -58,6 +59,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.an, name: 'aragonés – Aragonese'}, {code2: AppLanguage.an, name: 'aragonés – Aragonese'},
{code2: AppLanguage.ast, name: 'asturianu – Asturian'}, {code2: AppLanguage.ast, name: 'asturianu – Asturian'},
{code2: AppLanguage.ca, name: 'català – Catalan'}, {code2: AppLanguage.ca, name: 'català – Catalan'},
{code2: AppLanguage.cs, name: 'čeština – Czech'},
{code2: AppLanguage.cy, name: 'Cymraeg – Welsh'}, {code2: AppLanguage.cy, name: 'Cymraeg – Welsh'},
{code2: AppLanguage.da, name: 'dansk – Danish'}, {code2: AppLanguage.da, name: 'dansk – Danish'},
{code2: AppLanguage.de, name: 'Deutsch – German'}, {code2: AppLanguage.de, name: 'Deutsch – German'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More