diff --git a/.github/workflows/build-and-push-bskyweb-aws.yaml b/.github/workflows/build-and-push-bskyweb-aws.yaml index 2c8d09c776..52bb3cd18e 100644 --- a/.github/workflows/build-and-push-bskyweb-aws.yaml +++ b/.github/workflows/build-and-push-bskyweb-aws.yaml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Docker buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/build-and-push-bskyweb-ghcr.yaml b/.github/workflows/build-and-push-bskyweb-ghcr.yaml index 693e9a1d3b..4de9407866 100644 --- a/.github/workflows/build-and-push-bskyweb-ghcr.yaml +++ b/.github/workflows/build-and-push-bskyweb-ghcr.yaml @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Docker buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/build-and-push-embedr-aws.yaml b/.github/workflows/build-and-push-embedr-aws.yaml index a4e1bd2c88..b54920a208 100644 --- a/.github/workflows/build-and-push-embedr-aws.yaml +++ b/.github/workflows/build-and-push-embedr-aws.yaml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Docker buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/build-and-push-link-aws.yaml b/.github/workflows/build-and-push-link-aws.yaml index 31eaa8357f..a459021fae 100644 --- a/.github/workflows/build-and-push-link-aws.yaml +++ b/.github/workflows/build-and-push-link-aws.yaml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Docker buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/build-and-push-ogcard-aws.yaml b/.github/workflows/build-and-push-ogcard-aws.yaml index 3102b05975..a2d0c738cc 100644 --- a/.github/workflows/build-and-push-ogcard-aws.yaml +++ b/.github/workflows/build-and-push-ogcard-aws.yaml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Docker buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/build-submit-android.yml b/.github/workflows/build-submit-android.yml index 54f8723beb..c85a9a3caf 100644 --- a/.github/workflows/build-submit-android.yml +++ b/.github/workflows/build-submit-android.yml @@ -49,7 +49,7 @@ jobs: fi - name: ⬇️ Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 5 @@ -238,7 +238,7 @@ jobs: - name: 📎 Attach APK to GitHub Release id: attach 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: tag_name: ${{ github.ref_name }} files: Bluesky-${{ needs.build.outputs.package-version }}.apk diff --git a/.github/workflows/build-submit-ios.yml b/.github/workflows/build-submit-ios.yml index 30d75598cb..a07d046533 100644 --- a/.github/workflows/build-submit-ios.yml +++ b/.github/workflows/build-submit-ios.yml @@ -60,7 +60,7 @@ jobs: fi - name: ⬇️ Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 5 diff --git a/.github/workflows/bundle-deploy-eas-update.yml b/.github/workflows/bundle-deploy-eas-update.yml index af322011f4..ced0f98cae 100644 --- a/.github/workflows/bundle-deploy-eas-update.yml +++ b/.github/workflows/bundle-deploy-eas-update.yml @@ -52,7 +52,7 @@ jobs: fi - name: ⬇️ Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -70,7 +70,7 @@ jobs: - name: 📷 Check fingerprint and install dependencies 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: profile: ${{ inputs.channel || 'testflight' }} previous-commit-tag: ${{ inputs.runtimeVersion }} @@ -174,7 +174,7 @@ jobs: fi - name: ⬇️ Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 5 @@ -329,7 +329,7 @@ jobs: fi - name: ⬇️ Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 5 diff --git a/.github/workflows/claude-mention.yml b/.github/workflows/claude-mention.yml index 5d35d2fa63..04930a45fa 100644 --- a/.github/workflows/claude-mention.yml +++ b/.github/workflows/claude-mention.yml @@ -54,7 +54,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 1 @@ -65,7 +65,7 @@ jobs: aws-region: us-east-2 - name: Claude - uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150 + uses: anthropics/claude-code-action@30544b674398ee15c84819bd87caf8a87e8c7b55 # v1.0.154 with: use_bedrock: 'true' additional_permissions: | diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 14d11f17e8..8323548c27 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 1 @@ -51,7 +51,7 @@ jobs: aws-region: us-east-2 - name: Claude review - uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150 + uses: anthropics/claude-code-action@30544b674398ee15c84819bd87caf8a87e8c7b55 # v1.0.154 with: use_bedrock: 'true' additional_permissions: | diff --git a/.github/workflows/golang-test-lint.yml b/.github/workflows/golang-test-lint.yml index d61f0c153d..c22744b9a3 100644 --- a/.github/workflows/golang-test-lint.yml +++ b/.github/workflows/golang-test-lint.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Git Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go tooling uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -36,7 +36,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Git Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Set up Go tooling uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5b40ad541d..20b60c57d1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,7 +24,7 @@ jobs: job: [lint, prettier, typecheck] steps: - 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 run: | set -euo pipefail @@ -87,7 +87,7 @@ jobs: shard: [1, 2, 3, 4] steps: - 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 - name: Install node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 8e50a4beff..250ad038bd 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -25,7 +25,7 @@ jobs: notes: ${{ steps.notes.outputs.notes }} steps: - name: ⬇️ Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/nightly-update-source-languages.yaml b/.github/workflows/nightly-update-source-languages.yaml index e6094476a6..3a7ba7dfca 100644 --- a/.github/workflows/nightly-update-source-languages.yaml +++ b/.github/workflows/nightly-update-source-languages.yaml @@ -16,7 +16,7 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}} - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 diff --git a/.github/workflows/pull-request-comment.yml b/.github/workflows/pull-request-comment.yml index 4f152f96f1..3541e1be56 100644 --- a/.github/workflows/pull-request-comment.yml +++ b/.github/workflows/pull-request-comment.yml @@ -125,7 +125,7 @@ jobs: fi - name: ⬇️ Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ steps.pr-info.outputs.head-sha }} diff --git a/.github/workflows/pull-request-commit.yml b/.github/workflows/pull-request-commit.yml index df8a69c188..7506c07c15 100644 --- a/.github/workflows/pull-request-commit.yml +++ b/.github/workflows/pull-request-commit.yml @@ -27,7 +27,7 @@ jobs: pull-requests: write steps: - name: ⬇️ Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 @@ -116,7 +116,7 @@ jobs: pull-requests: write steps: - name: ⬇️ Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 100 @@ -134,7 +134,7 @@ jobs: - name: 📷 Check fingerprint and install dependencies 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: profile: pull-request @@ -162,3 +162,19 @@ jobs: with: header: fingerprint-diff delete: true + + - name: 🏷️ Label as fingerprint changed + if: ${{ steps.fingerprint.outputs.includes-changes }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh pr edit "$PR_NUMBER" --add-label "bot: fingerprint changed" || true + + - name: 🏷️ Remove fingerprint changed label + if: ${{ !steps.fingerprint.outputs.includes-changes }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh pr edit "$PR_NUMBER" --remove-label "bot: fingerprint changed" || true diff --git a/.github/workflows/sync-internal.yaml b/.github/workflows/sync-internal.yaml index 211bd7c806..b816b07f5b 100644 --- a/.github/workflows/sync-internal.yaml +++ b/.github/workflows/sync-internal.yaml @@ -14,7 +14,7 @@ jobs: if: github.repository == 'bluesky-social/social-app' steps: - name: Checkout public repo - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 # Don't persist the checkout auth header; the push below authenticates diff --git a/.github/workflows/verify-pnpm-lock.yml b/.github/workflows/verify-pnpm-lock.yml index cb68e2b4e7..8483e5fdb0 100644 --- a/.github/workflows/verify-pnpm-lock.yml +++ b/.github/workflows/verify-pnpm-lock.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out PR HEAD - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 56d06bbd85..b7b7b371a5 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -21,12 +21,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out Git repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 with: # Annotate the PR directly instead of uploading SARIF to the # security tab, and fail the check on any finding diff --git a/babel.config.js b/babel.config.js index faf9b3e36b..de000b9de8 100644 --- a/babel.config.js +++ b/babel.config.js @@ -18,19 +18,6 @@ module.exports = function (api) { plugins: [ '@lingui/babel-plugin-lingui-macro', ['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', { diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 152355c62a..c818b780f6 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -927,11 +927,6 @@ "count": 1 } }, - "src/lib/hooks/useTabFocusEffect.ts": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/lib/hooks/useToggleMutationQueue.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -1073,17 +1068,6 @@ "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": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -1956,15 +1940,6 @@ } }, "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": { "count": 2 }, @@ -2291,14 +2266,6 @@ "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": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -2314,14 +2281,6 @@ "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": { "@typescript-eslint/no-floating-promises": { "count": 3 diff --git a/index.js b/index.ts similarity index 100% rename from index.js rename to index.ts diff --git a/index.web.js b/index.web.ts similarity index 100% rename from index.web.js rename to index.web.ts diff --git a/modules/expo-bluesky-swiss-army/README.md b/modules/expo-bluesky-swiss-army/README.md index ca2861c28f..b1cc9eecc9 100644 --- a/modules/expo-bluesky-swiss-army/README.md +++ b/modules/expo-bluesky-swiss-army/README.md @@ -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) - **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 +- **NotificationSettings**: iOS handler that routes the system "notification settings" intent into the app ## 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 - 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: @@ -170,7 +186,9 @@ The module uses platform-specific file extensions to provide appropriate impleme ### 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 diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index 4cdc11e993..ef2648193e 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -5,7 +5,8 @@ "ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoBlueskyVisibilityViewModule", - "ExpoPlatformInfoModule" + "ExpoPlatformInfoModule", + "ExpoBlueskyNotificationSettingsModule" ] }, "android": { diff --git a/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec index be4b0eae45..54ce66526c 100644 --- a/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec +++ b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec @@ -10,6 +10,7 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'ExpoModulesCore' + s.dependency 'EXNotifications' # Swift/Objective-C compatibility s.pod_target_xcconfig = { diff --git a/modules/expo-bluesky-swiss-army/ios/NotificationSettings/ExpoBlueskyNotificationSettingsModule.swift b/modules/expo-bluesky-swiss-army/ios/NotificationSettings/ExpoBlueskyNotificationSettingsModule.swift new file mode 100644 index 0000000000..6374d33487 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/NotificationSettings/ExpoBlueskyNotificationSettingsModule.swift @@ -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) + } + } +} diff --git a/modules/expo-bluesky-video-compress/android/build.gradle b/modules/expo-bluesky-video-compress/android/build.gradle new file mode 100644 index 0000000000..be96c40154 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/build.gradle @@ -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 { +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt new file mode 100644 index 0000000000..f35698486a --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/CodecSelector.kt @@ -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) } + } + } +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt new file mode 100644 index 0000000000..30b1def7c6 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/ExpoBlueskyVideoCompressModule.kt @@ -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() + + 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 -> + 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() + } + } +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt new file mode 100644 index 0000000000..e2eee4768c --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/InputSurface.kt @@ -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(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)}") + } + } +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt new file mode 100644 index 0000000000..fdf8c6eca2 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/OutputSurface.kt @@ -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() + } + } +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt new file mode 100644 index 0000000000..a0cb25bce1 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/TextureRenderer.kt @@ -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 + } +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt new file mode 100644 index 0000000000..e9c99bc9c4 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoCompressor.kt @@ -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 { + // '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 { + 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 { + 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 + ) { + 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() + 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, + 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 { + 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 +} diff --git a/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt new file mode 100644 index 0000000000..7c214bddd0 --- /dev/null +++ b/modules/expo-bluesky-video-compress/android/src/main/java/expo/modules/blueskyvideocompress/VideoProber.kt @@ -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 { + 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 + } + } +} diff --git a/modules/expo-bluesky-video-compress/expo-module.config.json b/modules/expo-bluesky-video-compress/expo-module.config.json new file mode 100644 index 0000000000..c1c24a480d --- /dev/null +++ b/modules/expo-bluesky-video-compress/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "android"], + "ios": { + "modules": ["ExpoBlueskyVideoCompressModule"] + }, + "android": { + "modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"] + } +} diff --git a/modules/expo-bluesky-video-compress/index.ts b/modules/expo-bluesky-video-compress/index.ts new file mode 100644 index 0000000000..423d7f3ac2 --- /dev/null +++ b/modules/expo-bluesky-video-compress/index.ts @@ -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 { + return NativeModule.probe(uri) +} + +export function compress( + uri: string, + options: CompressOptions = {}, + callbacks?: CompressCallbacks, +): Promise { + 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((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))) + }) + }) +} diff --git a/modules/expo-bluesky-video-compress/ios/CodecCapability.swift b/modules/expo-bluesky-video-compress/ios/CodecCapability.swift new file mode 100644 index 0000000000..df95d9b7e8 --- /dev/null +++ b/modules/expo-bluesky-video-compress/ios/CodecCapability.swift @@ -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 + } + }() +} diff --git a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec new file mode 100644 index 0000000000..5bca8f0812 --- /dev/null +++ b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompress.podspec @@ -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 diff --git a/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift new file mode 100644 index 0000000000..3bcea3c9f4 --- /dev/null +++ b/modules/expo-bluesky-video-compress/ios/ExpoBlueskyVideoCompressModule.swift @@ -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() + } +} diff --git a/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift b/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift new file mode 100644 index 0000000000..e4a75398ea --- /dev/null +++ b/modules/expo-bluesky-video-compress/ios/VideoCompressor.swift @@ -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) 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) 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] + ) + } +} diff --git a/modules/expo-bluesky-video-compress/ios/VideoProber.swift b/modules/expo-bluesky-video-compress/ios/VideoProber.swift new file mode 100644 index 0000000000..4636ed7d3c --- /dev/null +++ b/modules/expo-bluesky-video-compress/ios/VideoProber.swift @@ -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 = ["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) + } +} diff --git a/modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts b/modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts new file mode 100644 index 0000000000..dcf63c729f --- /dev/null +++ b/modules/expo-bluesky-video-compress/src/ExpoBlueskyVideoCompressModule.ts @@ -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 + compress(uri: string, options: NativeCompressOptions): Promise + cancel(jobId: number): void + addListener( + eventName: 'onProgress', + listener: (event: ProgressEvent) => void, + ): EventSubscription +} + +export default requireNativeModule( + 'ExpoBlueskyVideoCompress', +) diff --git a/modules/expo-bluesky-video-compress/src/types.ts b/modules/expo-bluesky-video-compress/src/types.ts new file mode 100644 index 0000000000..df9b9ed1ac --- /dev/null +++ b/modules/expo-bluesky-video-compress/src/types.ts @@ -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 +} diff --git a/package.json b/package.json index 00a3fa15a6..c3da84d8ec 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,7 @@ "generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web", "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web", "icons:optimize": "svgo -f ./assets/icons", - "prettier": "prettier --check .", - "update-actions": "pnpm dlx actions-up --min-age 7" + "prettier": "prettier --check ." }, "dependencies": { "@atproto/api": "0.20.23", @@ -297,7 +296,6 @@ "jest-junit": "^16.0.0", "lint-staged": "^13.2.3", "prettier": "^3.8.3", - "react-native-dotenv": "^3.4.11", "react-refresh": "^0.14.0", "svgo": "^3.3.2", "ts-plugin-sort-import-suggestions": "^1.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7144888496..a3e6c9ed22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -848,9 +848,6 @@ importers: prettier: specifier: ^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: specifier: ^0.14.0 version: 0.14.2 @@ -8008,11 +8005,6 @@ packages: react: '*' 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: resolution: {integrity: sha512-mj/riptO4O8D9loG9N76M4VqLR9EQXLQkx/nN4GVgT/y46wpVlrqqLlDAqFVylTDEW/u3WvZhZph+8Iu64MsZw==} peerDependencies: @@ -17851,11 +17843,6 @@ snapshots: 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): dependencies: color: 4.2.3 diff --git a/src/App.native.tsx b/src/App.tsx similarity index 100% rename from src/App.native.tsx rename to src/App.tsx diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 8416a69b5e..3cb707733f 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1365,6 +1365,25 @@ export type Events = { engine: string 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': { uploadId: string engine: string diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index f49eb6d46a..7a5dfe503e 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -1,5 +1,4 @@ import {useCallback} from 'react' -import {View} from 'react-native' import {Sift, type UseSiftReturn} from '@bsky.app/sift' import {atoms as a, useTheme} from '#/alf' @@ -22,7 +21,7 @@ function renderItem( case 'search': return default: - return + return <> } } @@ -33,6 +32,7 @@ export function Autocomplete({ render = renderItem, onSelect, onDismiss, + fullWidth = false, }: { inverted?: boolean sift: UseSiftReturn @@ -40,6 +40,12 @@ export function Autocomplete({ render?: Parameters>[0]['render'] onSelect: (item: AutocompleteItem) => 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() @@ -50,6 +56,8 @@ export function Autocomplete({ useOnKeyboard('keyboardDidShow', updatePosition) useOnKeyboard('keyboardDidHide', updatePosition) + const maxWidth = IS_WEB && !fullWidth ? {maxWidth: 300} : {} + return ( diff --git a/src/components/Autocomplete/types.ts b/src/components/Autocomplete/types.ts index fef38ab796..6f2610d177 100644 --- a/src/components/Autocomplete/types.ts +++ b/src/components/Autocomplete/types.ts @@ -45,4 +45,5 @@ export type AutocompleteItemProps = Parameters< export type AutocompleteApi = { query: string items: AutocompleteItem[] + isFetching: boolean } diff --git a/src/components/Autocomplete/useAutocomplete/index.ts b/src/components/Autocomplete/useAutocomplete/index.ts index 280faf1f60..498cc766cd 100644 --- a/src/components/Autocomplete/useAutocomplete/index.ts +++ b/src/components/Autocomplete/useAutocomplete/index.ts @@ -118,6 +118,7 @@ export function useAutocomplete({ return { query: q, items, + isFetching: query.isFetching, } } diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index d27e0f4d85..212eaadb30 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -74,16 +74,17 @@ const BORDER_RADIUS = 20 const SQUARED_BORDER_RADIUS = 4 const DISPLAY_NAME_INSET = 20 -function messageIsReply( - message: - | ChatBskyConvoDefs.MessageView - | ChatBskyConvoDefs.DeletedMessageView - | null, -): boolean { +export type MessageItemNeighbor = + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null + +function messageIsReply(message: MessageItemNeighbor): boolean { return ( ChatBskyConvoDefs.isMessageView(message) && (ChatBskyConvoDefs.isMessageView(message.replyTo) || - ChatBskyConvoDefs.isDeletedMessageView(message.replyTo)) + ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) || + ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(message.replyTo)) ) } @@ -96,10 +97,7 @@ function isWithinClusterBoundary({ }: { isPending: boolean message: ChatBskyConvoDefs.MessageView - adjacentMessage: - | ChatBskyConvoDefs.MessageView - | ChatBskyConvoDefs.DeletedMessageView - | null + adjacentMessage: MessageItemNeighbor isFromSameSender: boolean direction: 'prev' | 'next' }): boolean { @@ -135,14 +133,8 @@ let MessageItem = ({ }: { item: ConvoItem & {type: 'message' | 'pending-message'} isGroupChat?: boolean - prevMessage: - | ChatBskyConvoDefs.MessageView - | ChatBskyConvoDefs.DeletedMessageView - | null - nextMessage: - | ChatBskyConvoDefs.MessageView - | ChatBskyConvoDefs.DeletedMessageView - | null + prevMessage: MessageItemNeighbor + nextMessage: MessageItemNeighbor relatedProfiles: Map }): React.ReactNode => { const t = useTheme() @@ -157,14 +149,22 @@ let MessageItem = ({ const {openReactions} = useMessageDialogs() const {scrollToMessage, highlightedMessage} = useMessageReplies() - // `replyTo` comes back hydrated as the referenced message (or a deleted- - // message tombstone). Narrow away the open-union fallback so we only render - // shapes we understand. + // `replyTo` comes back hydrated as the referenced message, a deleted-message + // tombstone, or a before-joined placeholder. Narrow away the open-union + // fallback so we only render shapes we understand. const replyTo = ChatBskyConvoDefs.isMessageView(message.replyTo) || - ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) + ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) || + ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(message.replyTo) ? message.replyTo : undefined + const replyToMessageId = + replyTo && !ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(replyTo) + ? replyTo.id + : undefined + const onPressReplyTo = replyToMessageId + ? () => scrollToMessage(replyToMessageId) + : undefined const isPending = item.type === 'pending-message' @@ -468,7 +468,7 @@ let MessageItem = ({ isGroupChat={isGroupChat} replierDisplayName={displayName} relatedProfiles={relatedProfiles} - onPress={() => scrollToMessage(replyTo.id)} + onPress={onPressReplyTo} /> ) : displayName && showDisplayName ? ( scrollToMessage(replyTo.id)} + onPress={onPressReplyTo} /> ) : null} - onPress: () => void + onPress?: () => void }) { const t = useTheme() const {t: l} = useLingui() const {currentAccount} = useSession() - const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did - const originalProfile = relatedProfiles.get(replyTo.sender.did) - const originalName = originalSenderIsSelf - ? null - : originalProfile - ? createSanitizedDisplayName(originalProfile) - : null + let caption: string = '' + if ( + ChatBskyConvoDefs.isMessageView(replyTo) || + ChatBskyConvoDefs.isDeletedMessageView(replyTo) + ) { + const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did + const originalProfile = relatedProfiles.get(replyTo.sender.did) + const originalName = originalSenderIsSelf + ? null + : originalProfile + ? createSanitizedDisplayName(originalProfile) + : 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 ( ) @@ -828,17 +840,25 @@ function ReplyQuote({ relatedProfiles, onPress, }: { - replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + replyTo: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | ChatBskyConvoDefs.MessageBeforeUserJoinedGroupView isFromSelf: boolean relatedProfiles: Map - onPress: () => void + onPress?: () => void }) { const t = useTheme() const {t: l} = useLingui() const getReplyPreviewText = useReplyPreviewText() + const senderDid = + ChatBskyConvoDefs.isMessageView(replyTo) || + ChatBskyConvoDefs.isDeletedMessageView(replyTo) + ? replyTo.sender.did + : undefined 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 // sender - mirroring how the message bubble itself is hidden. @@ -866,6 +886,12 @@ function ReplyQuote({ subtle = true } else if (ChatBskyConvoDefs.isMessageView(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 { text = l({message: '(deleted message)', comment: 'A reply summary in chat'}) subtle = true @@ -874,10 +900,13 @@ function ReplyQuote({ return ( - - {IS_NATIVE && ( - - )} - - - {error && ( - - {error.clean || error.raw || _(msg`An unknown error occurred.`)} - - )} - - ) -} diff --git a/src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx b/src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx deleted file mode 100644 index f22655063a..0000000000 --- a/src/features/liveEvents/components/LiveEventFeedsSettingsToggle.tsx +++ /dev/null @@ -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 ( - { - if (!isPending) { - update({type: 'toggleHideAllFeeds'}) - } - }}> - - - - Show live events in your Discover Feed - - - - - ) -} diff --git a/src/features/liveNow/components/EditLiveDialog.tsx b/src/features/liveNow/components/EditLiveDialog.tsx index 6d95d43859..cf913541a1 100644 --- a/src/features/liveNow/components/EditLiveDialog.tsx +++ b/src/features/liveNow/components/EditLiveDialog.tsx @@ -1,10 +1,6 @@ import {useMemo, useState} from 'react' import {View} from 'react-native' -import { - type AppBskyActorDefs, - AppBskyActorStatus, - type AppBskyEmbedExternal, -} from '@atproto/api' +import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' @@ -24,6 +20,7 @@ import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import { displayDuration, + getValidLiveStatusRecord, useLiveLinkMetaQuery, useRemoveLiveStatusMutation, useUpsertLiveStatusMutation, @@ -74,14 +71,10 @@ function DialogInner({ error: linkMetaError, } = useLiveLinkMetaQuery(debouncedUrl) - const record = useMemo(() => { - if (!AppBskyActorStatus.isRecord(status.record)) return null - const validation = AppBskyActorStatus.validateRecord(status.record) - if (validation.success) { - return validation.value - } - return null - }, [status]) + const record = useMemo( + () => getValidLiveStatusRecord(status.record), + [status], + ) const { mutate: goLive, diff --git a/src/features/liveNow/components/GoLiveDialog.tsx b/src/features/liveNow/components/GoLiveDialog.tsx index b22585ab1e..72438fdddc 100644 --- a/src/features/liveNow/components/GoLiveDialog.tsx +++ b/src/features/liveNow/components/GoLiveDialog.tsx @@ -20,7 +20,9 @@ import * as Select from '#/components/Select' import {Text} from '#/components/Typography' import { displayDuration, + getLiveLinkFromStatusRecord, getLiveServiceNames, + useActorStatus, useLiveLinkMetaQuery, useLiveNowConfig, useUpsertLiveStatusMutation, @@ -50,16 +52,20 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) { const control = Dialog.useDialogContext() const {_, i18n} = useLingui() const t = useTheme() - const [liveLink, setLiveLink] = useState('') const [liveLinkError, setLiveLinkError] = useState('') const [duration, setDuration] = useState(60) const moderationOpts = useModerationOpts() const tick = useTickEveryMinute() const liveNowConfig = useLiveNowConfig() + const status = useActorStatus(profile) const {formatted: allowedServices} = getLiveServiceNames( liveNowConfig.currentAccountAllowedHosts, ) + const [liveLink, setLiveLink] = useState(() => + getLiveLinkFromStatusRecord(status.record), + ) + const time = useCallback( (offset: number) => { void tick diff --git a/src/features/liveNow/utils.ts b/src/features/liveNow/utils.ts index f2a7c8c62f..a7326cd8e9 100644 --- a/src/features/liveNow/utils.ts +++ b/src/features/liveNow/utils.ts @@ -1,7 +1,32 @@ +import {AppBskyActorStatus, AppBskyEmbedExternal} from '@atproto/api' import {type I18n} from '@lingui/core' import {plural} from '@lingui/core/macro' import psl from 'psl' +/** + * Validates a raw status record and returns the typed record, or null if the + * value is not a valid `app.bsky.actor.status` record. + */ +export function getValidLiveStatusRecord( + statusRecord: unknown, +): AppBskyActorStatus.Record | null { + if (!AppBskyActorStatus.isRecord(statusRecord)) return null + const validation = AppBskyActorStatus.validateRecord(statusRecord) + if (!validation.success) return null + return validation.value +} + +/** + * Extracts the external link URI from a status record, if present. Returns an + * empty string when the record is invalid or has no external embed. + */ +export function getLiveLinkFromStatusRecord(statusRecord: unknown): string { + const record = getValidLiveStatusRecord(statusRecord) + if (!record) return '' + if (!AppBskyEmbedExternal.isMain(record.embed)) return '' + return record.embed.external.uri +} + export function displayDuration(i18n: I18n, durationInMinutes: number) { const roundedDurationInMinutes = Math.round(durationInMinutes) const hours = Math.floor(roundedDurationInMinutes / 60) diff --git a/src/lib/currency.ts b/src/lib/currency.ts deleted file mode 100644 index 9ba97ddce9..0000000000 --- a/src/lib/currency.ts +++ /dev/null @@ -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 = { - 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[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]) -} diff --git a/src/lib/custom-animations/ShrinkAndPop.ts b/src/lib/custom-animations/ShrinkAndPop.ts deleted file mode 100644 index ea2386c14e..0000000000 --- a/src/lib/custom-animations/ShrinkAndPop.ts +++ /dev/null @@ -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, - } -} diff --git a/src/lib/hooks/useTabFocusEffect.ts b/src/lib/hooks/useTabFocusEffect.ts deleted file mode 100644 index a54a3af73f..0000000000 --- a/src/lib/hooks/useTabFocusEffect.ts +++ /dev/null @@ -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]) -} diff --git a/src/lib/hooks/useTimer.ts b/src/lib/hooks/useTimer.ts deleted file mode 100644 index 8793ac5475..0000000000 --- a/src/lib/hooks/useTimer.ts +++ /dev/null @@ -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) - - // 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] -} diff --git a/src/lib/media/avatar-generator.tsx b/src/lib/media/avatar-generator.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index dfb2e17b4b..72fb2a246c 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -2,7 +2,9 @@ import {getVideoMetaData, Video} from 'react-native-compressor' import {type ImagePickerAsset} from 'expo-image-picker' 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' const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb @@ -12,9 +14,20 @@ export async function compressVideo( opts?: { signal?: AbortSignal onProgress?: (progress: number) => void + onProbe?: (metadata: ProbedMetadata) => void }, ): Promise { - 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') { // let's hope they're small enough that they don't need compression! diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts index 62e8469351..7f96141b74 100644 --- a/src/lib/media/video/compress.web.ts +++ b/src/lib/media/video/compress.web.ts @@ -23,7 +23,7 @@ import { COMPRESSION_MIN_SIZE_BYTES, COMPRESSION_TARGET_BITRATE, } from './constants' -import {type CompressedVideo} from './types' +import {type CompressedVideo, type ProbedMetadata} from './types' // Codecs to try in order of preference // avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM @@ -34,9 +34,10 @@ export async function compressVideo( opts?: { signal?: AbortSignal onProgress?: (progress: number) => void + onProbe?: (metadata: ProbedMetadata) => void }, ): Promise { - const {onProgress, signal} = opts || {} + const {onProgress, signal, onProbe} = opts || {} logger.debug('compress: starting', { uri: asset.uri.slice(0, 50), @@ -49,6 +50,18 @@ export async function compressVideo( const isGif = blob.type === 'image/gif' 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', { size: blob.size, mimeType: blob.type, @@ -98,6 +111,44 @@ export async function compressVideo( } } +async function probeWithMediaBunny(blob: Blob): Promise { + 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( width: number, height: number, diff --git a/src/lib/media/video/constants.ts b/src/lib/media/video/constants.ts index a328c64d68..60580efc8c 100644 --- a/src/lib/media/video/constants.ts +++ b/src/lib/media/video/constants.ts @@ -1,8 +1,10 @@ // Shared compression knobs. Mirrored between native (compress.ts) and web // (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 +// Output dimension cap when compressing, and skip threshold for source files. export const COMPRESSION_MAX_DIMENSION = 1920 -// Web skips compression entirely for files under this size; native applies its -// own threshold logic inside expo-bluesky-video-compress's probe step. +// Web only: files under this size skip compression entirely. Native applies +// its own threshold logic inside react-native-compressor. export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000 diff --git a/src/lib/media/video/telemetry.ts b/src/lib/media/video/telemetry.ts index 7ef5a874ef..34eb431420 100644 --- a/src/lib/media/video/telemetry.ts +++ b/src/lib/media/video/telemetry.ts @@ -2,7 +2,10 @@ import {Platform} from 'react-native' import {type ImagePickerAsset} from 'expo-image-picker' 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 {type Metrics} from '#/analytics/metrics' @@ -28,6 +31,7 @@ export type VideoTelemetry = { readonly engine: string picked: () => void compressStarted: () => void + probed: (metadata: ProbedMetadata) => void compressSkipped: (video: { size: number 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}) { metric('video:upload:compressSkipped', { uploadId, diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts index d22832794a..6825f03ba3 100644 --- a/src/lib/media/video/types.ts +++ b/src/lib/media/video/types.ts @@ -18,3 +18,20 @@ export type CompressedVideo = { // bytes were actually re-encoded. 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 +} diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 1c2e6dbe5a..008b2ef73f 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -225,6 +225,14 @@ export function useNotificationsRegistration() { }, [currentAccount, getAndRegisterPushToken, registerPushToken, aa]) } +/** + * Tracks whether we have already shown the OS notification permission prompt + * during this app session. On Android `canAskAgain` stays true after a single + * in-app denial, so without this guard a later call site (e.g. Home after + * Login) would surface a second prompt. Resets on app restart. + */ +let hasRequestedPermissionsThisSession = false + export function useRequestNotificationsPermission() { const ax = useAnalytics() const {currentAccount} = useSession() @@ -249,7 +257,31 @@ export function useRequestNotificationsPermission() { return } - const res = await Notifications.requestPermissionsAsync() + if (hasRequestedPermissionsThisSession) { + return + } + hasRequestedPermissionsThisSession = true + + 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`, { context: context, diff --git a/src/platform/markBundleStartTime.web.ts b/src/platform/markBundleStartTime.ts similarity index 100% rename from src/platform/markBundleStartTime.web.ts rename to src/platform/markBundleStartTime.ts diff --git a/src/screens/Bookmarks/index.tsx b/src/screens/Bookmarks.tsx similarity index 94% rename from src/screens/Bookmarks/index.tsx rename to src/screens/Bookmarks.tsx index 16d25dcd05..fdeb91777c 100644 --- a/src/screens/Bookmarks/index.tsx +++ b/src/screens/Bookmarks.tsx @@ -14,13 +14,13 @@ import { useNavigation, } from '@react-navigation/native' -import {useCleanError} from '#/lib/hooks/useCleanError' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' +import {cleanError} from '#/lib/strings/errors' import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery' import {Post} from '#/view/com/post/Post' @@ -92,7 +92,6 @@ type ListItem = function BookmarksInner() { const initialNumToRender = useInitialNumToRender() - const cleanError = useCleanError() const [isPTRing, setIsPTRing] = useState(false) const trackPostView = usePostViewTracking('Bookmarks') const { @@ -104,10 +103,6 @@ function BookmarksInner() { error, refetch, } = useBookmarksQuery() - const cleanedError = useMemo(() => { - const {raw, clean} = cleanError(error) - return clean || raw - }, [error, cleanError]) const onRefresh = useCallback(async () => { setIsPTRing(true) @@ -174,10 +169,10 @@ function BookmarksInner() { renderItem={renderItem} keyExtractor={keyExtractor} refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} + onRefresh={() => void onRefresh()} + onEndReached={() => void onEndReached()} onEndReachedThreshold={4} - onItemSeen={item => { + onItemSeen={(item: ListItem) => { if (item.type === 'bookmark') { trackPostView(item.bookmark.item) } @@ -185,7 +180,7 @@ function BookmarksInner() { ListFooterComponent={ @@ -209,7 +204,6 @@ function BookmarkNotFound({ const t = useTheme() const {_} = useLingui() const {mutateAsync: bookmark} = useBookmarkMutation() - const cleanError = useCleanError() const remove = async () => { try { @@ -217,9 +211,8 @@ function BookmarkNotFound({ toast.show(_(msg`Removed from saved posts`), { type: 'info', }) - } catch (e: any) { - const {raw, clean} = cleanError(e) - toast.show(clean || raw || e, { + } catch (err) { + toast.show(cleanError(err), { type: 'error', }) } @@ -259,7 +252,7 @@ function BookmarkNotFound({ label={_(msg`Remove from saved posts`)} size="tiny" color="secondary" - onPress={remove}> + onPress={() => void remove()}> Remove diff --git a/src/screens/Bookmarks/components/EmptyState.tsx b/src/screens/Bookmarks/components/EmptyState.tsx deleted file mode 100644 index cf70e7385c..0000000000 --- a/src/screens/Bookmarks/components/EmptyState.tsx +++ /dev/null @@ -1,60 +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 {atoms as a, useTheme} from '#/alf' -import {ButtonText} from '#/components/Button' -import {BookmarkDeleteLarge} from '#/components/icons/Bookmark' -import {Link} from '#/components/Link' -import {Text} from '#/components/Typography' - -export function EmptyState() { - const t = useTheme() - const {_} = useLingui() - - return ( - - - - - Nothing saved yet - - - - - - - Go home - - - - - - ) -} diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx index 5057f4a68d..e4f08291c9 100644 --- a/src/screens/Hashtag.tsx +++ b/src/screens/Hashtag.tsx @@ -121,7 +121,9 @@ export default function HashtagScreen({ {headerTitle} {author && ( - {_(msg`From @${sanitizedAuthor}`)} + {author.startsWith('did:') + ? _(msg`From ${sanitizedAuthor}`) + : _(msg`From @${sanitizedAuthor}`)} )} @@ -172,11 +174,9 @@ function HashtagScreenTab({ const isCashtag = fullTag.startsWith('$') const queryParam = useMemo(() => { - // Cashtags need # prefix for search: "#$BTC" or "#$BTC from:author" - const searchTag = isCashtag ? `#${fullTag}` : fullTag - if (!author) return searchTag - return `${searchTag} from:${author}` - }, [fullTag, author, isCashtag]) + // Cashtags need # prefix for search: "#$BTC" + return isCashtag ? `#${fullTag}` : fullTag + }, [fullTag, isCashtag]) const { data, @@ -188,7 +188,7 @@ function HashtagScreenTab({ refetch, fetchNextPage, hasNextPage, - } = useSearchPostsQuery({query: queryParam, sort, enabled: active}) + } = useSearchPostsQuery({query: queryParam, sort, enabled: active, author}) const posts = useMemo(() => { return data?.pages.flatMap(page => page.posts) || [] diff --git a/src/screens/Log.tsx b/src/screens/Log.tsx index b769ef5c5a..b7e5c1cf54 100644 --- a/src/screens/Log.tsx +++ b/src/screens/Log.tsx @@ -60,7 +60,7 @@ export function LogScreen({}: NativeStackScreenProps< )} - + {entry.context && ( ({String(entry.context)}) diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index 1976e973a3..2b98106d2b 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -60,7 +60,10 @@ import {MessageComposer} from '#/screens/Messages/components/MessageComposer' import {MessageListError} from '#/screens/Messages/components/MessageListError' import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {DateDivider} from '#/components/dms/DateDivider' -import {MessageItem} from '#/components/dms/MessageItem' +import { + MessageItem, + type MessageItemNeighbor, +} from '#/components/dms/MessageItem' import {MessageOverlays} from '#/components/dms/MessageOverlays' import {MessageRepliesProvider} from '#/components/dms/MessageReplies' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' @@ -104,7 +107,7 @@ function keyExtractor(item: RenderItem) { function getNeighborMessage( items: RenderItem[], index: number, -): ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null { +): MessageItemNeighbor { const neighbor = items[index] if (!neighbor) return null if ( @@ -200,6 +203,21 @@ export function MessagesList({ // the bottom. const isAtBottom = useSharedValue(true) + // Set when the local user sends a message so we follow it to the end even + // from a scrolled-up position. On native, onContentSizeChange can't be relied + // on: with maintainVisibleContentPosition anchored to item 0, appending a + // message below the viewport reports no content-size change, so that callback + // never fires for the send. We watch the rendered item count instead and + // scroll imperatively once our pending message lands (APP-2223). On web, + // onContentSizeChange also fires for the send, so the scroll may run from both + // paths - both target the end, so the result is correct. + const pendingSendScroll = useRef(false) + + // Handle for the in-flight send-scroll burst (see startSendScrollBurst). Held + // here so the re-init effect below can cancel a burst that belongs to the + // previous convo lifecycle. + const sendScrollRaf = useRef(0) + // This will be used on web to assist in determining if we need to maintain the content offset const isAtTop = useSharedValue(true) @@ -220,6 +238,12 @@ export function MessagesList({ if (prevHasScrolled.current && !hasScrolled) { hasInitiallyScrolled.current = false setDidInitialScroll(false) + // Drop any unfired send pin and stop an in-flight scroll burst: the + // initial-scroll path owns positioning during re-init, and the pending + // message they referred to belongs to the previous lifecycle. + pendingSendScroll.current = false + cancelAnimationFrame(sendScrollRaf.current) + sendScrollRaf.current = 0 } prevHasScrolled.current = hasScrolled }, [hasScrolled]) @@ -253,6 +277,59 @@ export function MessagesList({ } }, [convoState.status]) + // Scroll to a saturating offset rather than scrollToEnd: when the keyboard is + // open, KeyboardChatScrollView lifts the content via extraContentPadding, and + // scrollToEnd's internal target is unaware of that lift, so it lands short by + // the keyboard height. An over-large offset clamps to the true bottom. + const scrollSendToBottom = useCallback(() => { + flatListRef.current?.scrollToOffset({ + offset: Number.MAX_SAFE_INTEGER, + animated: true, + }) + }, [flatListRef]) + + // A single scroll can't follow a send to the bottom: a multi-line send settles + // over several layout passes (the tall pending item being measured, then the + // composer collapsing back to one line), and the content bottom keeps moving + // after the scroll target was clamped. We can't drive this off the composer's + // height drop either - that signal is global, outlives the send, and races the + // pending-message append. Instead we re-assert the saturating scroll across a + // short window keyed to the send. Each call re-clamps to the *current* true + // bottom, so the last one lands settled regardless of how many passes it took. + // The burst is bounded and self-terminating, so it can't leak into a later + // unrelated resize, and it polls geometry rather than depending on + // onContentSizeChange (which doesn't fire for the send append on native - see + // the pendingSendScroll declaration). + const stopSendScrollBurst = useCallback(() => { + cancelAnimationFrame(sendScrollRaf.current) + sendScrollRaf.current = 0 + }, []) + const startSendScrollBurst = useCallback(() => { + stopSendScrollBurst() + const deadline = Date.now() + 200 + const tick = () => { + scrollSendToBottom() + sendScrollRaf.current = + Date.now() < deadline ? requestAnimationFrame(tick) : 0 + } + tick() + }, [scrollSendToBottom, stopSendScrollBurst]) + + // Cancel any in-flight burst on unmount. + useEffect(() => stopSendScrollBurst, [stopSendScrollBurst]) + + // Follow a just-sent message to the end. This runs when the rendered item + // count changes, but only fires once the tail item is our own optimistic + // pending message (pending-message items are local-only). That way a foreign + // message arriving between send and our append doesn't consume the pin or yank + // a scrolled-up reader down to it - the pin waits for our message to land. + useEffect(() => { + if (!pendingSendScroll.current) return + if (renderItems.at(-1)?.type !== 'pending-message') return + pendingSendScroll.current = false + startSendScrollBurst() + }, [renderItems, startSendScrollBurst]) + // -- Scroll handling // Every time the content size changes, that means one of two things is happening: @@ -479,6 +556,11 @@ export function MessagesList({ setHasScrolled(true) } + // Sending your own message should always take you to it, regardless of + // current scroll position. The effect watching renderItems.length scrolls + // to the end once the pending message is appended. + pendingSendScroll.current = true + convoState.sendMessage( { text: rt.text, diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx index 88bc2f181e..684a9fe53b 100644 --- a/src/screens/Profile/Header/DisplayName.tsx +++ b/src/screens/Profile/Header/DisplayName.tsx @@ -4,7 +4,8 @@ import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {type Shadow} from '#/state/cache/types' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' +import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' export function ProfileHeaderDisplayName({ @@ -18,7 +19,7 @@ export function ProfileHeaderDisplayName({ const {gtMobile} = useBreakpoints() return ( - + {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), moderation.ui('displayName'), )} + + + ) diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 051e8497cd..da01ded772 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -13,7 +13,6 @@ import {Trans} from '@lingui/react/macro' import {useHaptics} from '#/lib/haptics' import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import { @@ -22,7 +21,7 @@ import { } from '#/state/queries/profile' import {useRequireAuth, useSession} from '#/state/session' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' -import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, platform} from '#/alf' import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' @@ -34,16 +33,15 @@ import { KnownFollowers, shouldShowKnownFollowers, } from '#/components/KnownFollowers' -import {ProfileBadges} from '#/components/ProfileBadges' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import * as Toast from '#/components/Toast' -import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_IOS, IS_NATIVE} from '#/env' import {InviteFriendsDialog} from '#/features/inviteFriends' import {useActorStatus} from '#/features/liveNow' import {GermButton} from '../components/GermButton' +import {ProfileHeaderDisplayName} from './DisplayName' import {EditProfileDialog} from './EditProfileDialog' import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderMetrics} from './Metrics' @@ -65,8 +63,6 @@ let ProfileHeaderStandard = ({ hideBackButton = false, isPlaceholderProfile, }: Props): React.ReactNode => { - const t = useTheme() - const {gtMobile} = useBreakpoints() const profile = useProfileShadow(profileUnshadowed) const {currentAccount} = useSession() @@ -138,26 +134,10 @@ let ProfileHeaderStandard = ({ - - - {sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - )} - - - - - + {!isPlaceholderProfile && !isBlockedUser && ( diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx index 5eb1fa60e6..20a71b5daf 100644 --- a/src/screens/Search/Shell.tsx +++ b/src/screens/Search/Shell.tsx @@ -23,7 +23,6 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {MagnifyingGlassIcon} from '#/lib/icons' import {type NavigationProp, type SearchParams} from '#/lib/routes/types' import {listenSoftReset} from '#/state/events' -import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import { unstableCacheProfileView, useProfilesQuery, @@ -43,8 +42,8 @@ import { } from '#/screens/Search/searchParams' import {makeSearchQuery} from '#/screens/Search/utils' import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' +import {useAutocomplete} from '#/components/Autocomplete' import {Button, ButtonIcon} from '#/components/Button' -import {SearchInput} from '#/components/forms/SearchInput' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight' import * as Layout from '#/components/Layout' @@ -57,6 +56,7 @@ import type * as bsky from '#/types/bsky' import {AdvancedSearchDialog} from './components/AdvancedSearchDialog' import {AutocompleteResults} from './components/AutocompleteResults' import {DetectedLanguagesAdmonition} from './components/DetectedLanguagesAdmonition' +import {SearchAutocompleteInput} from './components/SearchAutocompleteInput' import {SearchHistory} from './components/SearchHistory' import {SearchLanguageDropdown} from './components/SearchLanguageDropdown' import {Explore} from './Explore' @@ -125,8 +125,16 @@ export function SearchScreenShell({ setSearchText(text) }, []) - const {data: autocompleteData, isFetching: isAutocompleteFetching} = - useActorAutocompleteQuery(searchText, true) + const {items: autocompleteItems, isFetching: isAutocompleteFetching} = + useAutocomplete({ + type: 'profile', + /* + * On web the dropdown (SearchAutocompleteInput) owns its own autocomplete + * query; only the native inline list consumes this one, so pass an empty + * query on web to keep the hook a no-op instead of doing wasted work. + */ + query: IS_NATIVE ? searchText : '', + }) const [showAutocomplete, setShowAutocomplete] = useState(false) @@ -388,6 +396,38 @@ export function SearchScreenShell({ [updateProfileHistory, queryClient], ) + /** + * Web only. Selecting a profile from the anchored autocomplete dropdown. + */ + const onSelectProfile = useCallback( + (profile: bsky.profile.AnyProfileView, position: number) => { + ax.metric('search:autocomplete:press', { + profileDid: profile.did, + position, + }) + handleProfileClick(profile) + navigation.navigate('Profile', {name: profile.handle}) + }, + [ax, handleProfileClick, navigation], + ) + + /** + * Web only. Selecting the "Search for X" row from the anchored autocomplete + * dropdown. This runs the typed query as-is (not a suggested profile), so it + * is attributed to `typed` rather than `autocomplete`. + */ + const onSelectSearch = useCallback( + (value: string) => { + ax.metric('search:query', { + source: 'typed', + filterCount: countActiveFilters(filters), + }) + updateSearchText(value) + navigateToItem(value) + }, + [ax, filters, navigateToItem, updateSearchText], + ) + const onSoftReset = useCallback(() => { if (IS_WEB) { /* @@ -495,13 +535,11 @@ export function SearchScreenShell({ {isExplore ? Explore : Search} - {showFilters ? ( - advancedSearchV2Enabled ? null : ( - - ) + {showFilters && !advancedSearchV2Enabled ? ( + ) : ( )} @@ -528,7 +566,7 @@ export function SearchScreenShell({ size="large" variant="ghost" color="secondary" - shape="rectangular" + shape="round" style={[a.px_sm]} onPress={onPressCancelSearch} hitSlop={HITSLOP_10}> @@ -536,7 +574,7 @@ export function SearchScreenShell({ )} - @@ -589,10 +630,10 @@ export function SearchScreenShell({ display: showAutocomplete && !fixedParams ? 'flex' : 'none', flex: 1, }}> - {searchText.length > 0 ? ( + {searchText.length > 0 && IS_NATIVE ? ( void onResultPress: () => void - onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void + onProfileClick: (profile: bsky.profile.AnyProfileView) => void }): React.ReactNode => { const ax = useAnalytics() - const {_} = useLingui() + const {t: l} = useLingui() const moderationOpts = useModerationOpts() + return ( <> - {(isAutocompleteFetching && !autocompleteData?.length) || - !moderationOpts ? ( + {(isFetching && !items.length) || !moderationOpts ? ( - - + + ) : ( @@ -51,7 +46,7 @@ let AutocompleteResults = ({ keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag"> - {autocompleteData?.map((item, index) => ( - { - ax.metric('search:autocomplete:press', { - profileDid: item.did, - position: index, - }) - onProfileClick(item) - onResultPress() - }} - /> - ))} + {items.map((item, index) => { + if (item.type !== 'profile') return null + return ( + { + ax.metric('search:autocomplete:press', { + profileDid: item.profile.did, + position: index, + }) + onProfileClick(item.profile) + onResultPress() + }} + /> + ) + })} )} @@ -95,12 +93,12 @@ let SearchLinkCard = ({ onPress?: () => void style?: ViewStyle }): React.ReactNode => { - const pal = usePalette('default') + const t = useTheme() const inner = ( - + style={[a.flex_1, a.py_lg, a.px_md, t.atoms.border_contrast_low, style]}> + {label} @@ -117,18 +115,13 @@ let SearchLinkCard = ({ ) } - return ( - - - - {label} - - - - ) + if (to) { + return ( + + {inner} + + ) + } + + return inner } diff --git a/src/screens/Search/components/SearchAutocompleteInput/index.native.tsx b/src/screens/Search/components/SearchAutocompleteInput/index.native.tsx new file mode 100644 index 0000000000..9ac0cb488b --- /dev/null +++ b/src/screens/Search/components/SearchAutocompleteInput/index.native.tsx @@ -0,0 +1,17 @@ +import {SearchInput} from '#/components/forms/SearchInput' +import {type SearchAutocompleteInputProps} from './shared' + +/** + * Native: renders the search input as-is. Typed results are shown inline in the + * full-page list (see Shell.tsx), so there is no anchored dropdown here. See + * index.tsx for the web (floating Sift dropdown) variant. + */ +export function SearchAutocompleteInput({ + // web-only props are ignored on native + fixedParams: _fixedParams, + onSelectProfile: _onSelectProfile, + onSelectSearch: _onSelectSearch, + ...rest +}: SearchAutocompleteInputProps) { + return +} diff --git a/src/screens/Search/components/SearchAutocompleteInput/index.tsx b/src/screens/Search/components/SearchAutocompleteInput/index.tsx new file mode 100644 index 0000000000..0e1cca113e --- /dev/null +++ b/src/screens/Search/components/SearchAutocompleteInput/index.tsx @@ -0,0 +1,122 @@ +import {useRef, useState} from 'react' +import {type TextInput, View} from 'react-native' +import {useSift} from '@bsky.app/sift' + +import {mergeRefs} from '#/lib/merge-refs' +import {atoms as a} from '#/alf' +import { + Autocomplete, + type AutocompleteItem, + useAutocomplete, +} from '#/components/Autocomplete' +import {SearchInput} from '#/components/forms/SearchInput' +import {type SearchAutocompleteInputProps} from './shared' + +/** + * Web: typed results float in a Sift dropdown anchored to the search input + * (matching the desktop nav-bar search). Search history continues to render + * full-page underneath the input in Shell.tsx, so it is not shown here. See + * index.native.tsx for the native (inline list) variant. + */ +export function SearchAutocompleteInput({ + fixedParams, + onSelectProfile, + onSelectSearch, + value = '', + onFocus, + onBlur, + onChangeText, + ref, + ...rest +}: SearchAutocompleteInputProps) { + const [focused, setFocused] = useState(false) + /* + * Sift dismisses on Escape without blurring the input, so `focused` stays + * true. Track that dismissal separately (reset on type or refocus) rather + * than clearing `focused`, otherwise the dropdown wouldn't reopen on the next + * keystroke while the input is still focused. + */ + const [dismissed, setDismissed] = useState(false) + const inputRef = useRef(null) + + const sift = useSift({ + offset: a.p_sm.padding, + placement: 'bottom', + }) + + const active = focused && !dismissed + + const {items} = useAutocomplete({ + type: 'profile', + // The dropdown only shows while active, so don't fetch otherwise. This + // avoids a typeahead request on mount when arriving with text already + // present (e.g. /search?q=foo). + query: active ? value : '', + showSearchFallback: true, + }) + + const showDropdown = + active && !fixedParams && value.length > 0 && items.length > 0 + + function onSelect(item: AutocompleteItem) { + if (item.type === 'profile') { + const position = items.filter(i => i.type === 'profile').indexOf(item) + onSelectProfile?.(item.profile, position) + } else if (item.type === 'search') { + onSelectSearch?.(item.value) + } + inputRef.current?.blur() + } + + /* + * setAnchor goes on the full-width wrapper View so the dropdown matches the + * input's container width; the input ref (targetProps.ref) lands on the inner + * TextInput for Sift's positioning math, and the remaining combobox a11y + * props are spread onto the input. + */ + const {setAnchor} = sift.refs + const {ref: inputAnchorRef, ...comboboxProps} = sift.targetProps + + return ( + void sift.updatePosition()}> + { + setDismissed(false) + onChangeText?.(text) + }} + onFocus={e => { + setDismissed(false) + setFocused(true) + onFocus?.(e) + }} + onBlur={e => { + setFocused(false) + onBlur?.(e) + }} + /> + {showDropdown && ( + setDismissed(true)} + fullWidth + /> + )} + + ) +} diff --git a/src/screens/Search/components/SearchAutocompleteInput/shared.ts b/src/screens/Search/components/SearchAutocompleteInput/shared.ts new file mode 100644 index 0000000000..13f5de5b55 --- /dev/null +++ b/src/screens/Search/components/SearchAutocompleteInput/shared.ts @@ -0,0 +1,23 @@ +import {type SearchInput} from '#/components/forms/SearchInput' +import type * as bsky from '#/types/bsky' + +type SearchInputProps = React.ComponentProps + +export type SearchAutocompleteInputProps = SearchInputProps & { + /** + * When the search has fixed params (e.g. ProfileSearch), the web dropdown is + * suppressed. + */ + fixedParams?: boolean + /** + * Web only. Called when a profile result in the dropdown is selected. + */ + onSelectProfile?: ( + profile: bsky.profile.AnyProfileView, + position: number, + ) => void + /** + * Web only. Called when the "Search for X" row in the dropdown is selected. + */ + onSelectSearch?: (value: string) => void +} diff --git a/src/screens/Search/components/SearchProfileCard.tsx b/src/screens/Search/components/SearchProfileCard.tsx index fdec03c118..a5ca18ee13 100644 --- a/src/screens/Search/components/SearchProfileCard.tsx +++ b/src/screens/Search/components/SearchProfileCard.tsx @@ -1,6 +1,6 @@ import {useCallback} from 'react' import {View} from 'react-native' -import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api' +import {type ModerationOpts} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' @@ -10,13 +10,14 @@ import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {atoms as a, useTheme} from '#/alf' import {Link} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' +import type * as bsky from '#/types/bsky' export function SearchProfileCard({ profile, moderationOpts, onPress: onPressInner, }: { - profile: AppBskyActorDefs.ProfileViewBasic + profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onPress?: () => void }) { diff --git a/src/screens/Settings/NotificationSettings/index.tsx b/src/screens/Settings/NotificationSettings/index.tsx index 7df759de32..00318a9180 100644 --- a/src/screens/Settings/NotificationSettings/index.tsx +++ b/src/screens/Settings/NotificationSettings/index.tsx @@ -82,7 +82,19 @@ export function NotificationSettingsScreen({}: Props) { const onRequestPermissions = async () => { if (IS_WEB) return if (permissions?.canAskAgain) { - const response = await Notification.requestPermissionsAsync() + const response = await Notification.requestPermissionsAsync({ + ios: { + /* + * These default to true only when no argument is passed, so set them + * explicitly to preserve behavior alongside + * provideAppNotificationSettings. + */ + allowAlert: true, + allowBadge: true, + allowSound: true, + provideAppNotificationSettings: true, + }, + }) queryClient.setQueryData(RQKEY, response) } else { if (IS_ANDROID) { diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index b8a2a9f39f..c0eb25f54a 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -454,7 +454,7 @@ export function usePinnedFeedsInfos() { pinnedItems.map(f => f.value), ), gcTime: GCTIME.INFINITY, - staleTime: STALE.INFINITY, + staleTime: STALE.MINUTES.FIFTEEN, enabled: !isLoadingPrefs, queryFn: async () => { if (!hasSession) { diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts index 183d8c883a..9a5ceff577 100644 --- a/src/state/queries/index.ts +++ b/src/state/queries/index.ts @@ -11,6 +11,7 @@ export const STALE = { ONE: MINUTE, THREE: 3 * MINUTE, FIVE: 5 * MINUTE, + FIFTEEN: 15 * MINUTE, THIRTY: 30 * MINUTE, }, HOURS: { diff --git a/src/state/queries/search-posts-v2.ts b/src/state/queries/search-posts-v2.ts index 506240e066..a6241808b0 100644 --- a/src/state/queries/search-posts-v2.ts +++ b/src/state/queries/search-posts-v2.ts @@ -74,14 +74,6 @@ export function useSearchPostsV2Query({ */ const {q, ...embedded} = extractSearchPostsParams(query) const builtFilters = buildSearchPostsV2Filters(embedded, filters) - /* - * v2 defaults to a recent-post window; the Latest tab keeps that, while - * Top searches the full index. But an explicit since/until date filter - * must search the full index too, otherwise the recent window would - * silently override the user's date range and return nothing for older - * dates. - */ - const hasDateFilter = !!(builtFilters.since || builtFilters.until) const res = await agent.app.bsky.feed.searchPostsV2({ ...builtFilters, query: q, @@ -92,7 +84,7 @@ export function useSearchPostsV2Query({ * the v1 'latest' label. */ sort: sort === 'latest' ? 'recent' : sort, - allTime: sort !== 'latest' || hasDateFilter, + allTime: true, }) return res.data }, diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts index 1fc8963caa..d951f67064 100644 --- a/src/state/queries/search-posts.ts +++ b/src/state/queries/search-posts.ts @@ -22,29 +22,35 @@ import { } from './util' const searchPostsQueryKeyRoot = 'search-posts' -const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [ - searchPostsQueryKeyRoot, +const searchPostsQueryKey = ({ query, sort, -] + author, +}: { + query: string + sort?: string + author?: string +}) => [searchPostsQueryKeyRoot, query, sort, author] export function useSearchPostsQuery({ query, sort, enabled, + author, }: { query: string sort?: 'top' | 'latest' enabled?: boolean + author?: string }) { const agent = useAgent() const moderationOpts = useModerationOpts() const selectArgs = useMemo( () => ({ - isSearchingSpecificUser: /from:(\w+)/.test(query), + isSearchingSpecificUser: !!author || /from:(\w+)/.test(query), moderationOpts, }), - [query, moderationOpts], + [query, author, moderationOpts], ) const lastRun = useRef<{ data: InfiniteData @@ -59,13 +65,14 @@ export function useSearchPostsQuery({ QueryKey, string | undefined >({ - queryKey: searchPostsQueryKey({query, sort}), + queryKey: searchPostsQueryKey({query, sort, author}), queryFn: async ({pageParam}) => { const res = await agent.app.bsky.feed.searchPosts({ q: query, limit: 25, cursor: pageParam, sort, + author, }) return res.data }, diff --git a/src/state/queries/suggested-feeds.ts b/src/state/queries/suggested-feeds.ts deleted file mode 100644 index 9344cf628d..0000000000 --- a/src/state/queries/suggested-feeds.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {type AppBskyFeedGetSuggestedFeeds} from '@atproto/api' -import { - type InfiniteData, - type QueryKey, - useInfiniteQuery, -} from '@tanstack/react-query' - -import {STALE} from '#/state/queries' -import {useAgent} from '#/state/session' - -const suggestedFeedsQueryKeyRoot = 'suggestedFeeds' -export const suggestedFeedsQueryKey = [suggestedFeedsQueryKeyRoot] - -export function useSuggestedFeedsQuery() { - const agent = useAgent() - return useInfiniteQuery< - AppBskyFeedGetSuggestedFeeds.OutputSchema, - Error, - InfiniteData, - QueryKey, - string | undefined - >({ - staleTime: STALE.HOURS.ONE, - queryKey: suggestedFeedsQueryKey, - queryFn: async ({pageParam}) => { - const res = await agent.app.bsky.feed.getSuggestedFeeds({ - limit: 10, - cursor: pageParam, - }) - return res.data - }, - initialPageParam: undefined, - getNextPageParam: lastPage => lastPage.cursor, - }) -} diff --git a/src/state/shell/post-progress.tsx b/src/state/shell/post-progress.tsx deleted file mode 100644 index 894e479cce..0000000000 --- a/src/state/shell/post-progress.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import {createContext, useContext} from 'react' - -interface PostProgressState { - progress: number - status: 'pending' | 'success' | 'error' | 'idle' - error?: string -} - -const PostProgressContext = createContext({ - progress: 0, - status: 'idle', -}) -PostProgressContext.displayName = 'PostProgressContext' - -export function Provider() {} - -export function usePostProgress() { - return useContext(PostProgressContext) -} diff --git a/src/state/shell/starter-pack.tsx b/src/state/shell/starter-pack.tsx deleted file mode 100644 index 149be21c87..0000000000 --- a/src/state/shell/starter-pack.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import {createContext, useContext, useState} from 'react' - -type StateContext = - | { - uri: string - isClip?: boolean - } - | undefined -type SetContext = (v: StateContext) => void - -const stateContext = createContext(undefined) -stateContext.displayName = 'ActiveStarterPackStateContext' -const setContext = createContext((_: StateContext) => {}) -setContext.displayName = 'ActiveStarterPackSetContext' - -export function Provider({children}: {children: React.ReactNode}) { - const [state, setState] = useState() - - return ( - - {children} - - ) -} - -export const useActiveStarterPack = () => useContext(stateContext) -export const useSetActiveStarterPack = () => useContext(setContext) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 3567f20b5d..a2cf83c75e 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -13,7 +13,6 @@ import { ActivityIndicator, BackHandler, Keyboard, - KeyboardAvoidingView, type LayoutChangeEvent, ScrollView, type StyleProp, @@ -21,6 +20,7 @@ import { View, type ViewStyle, } from 'react-native' +import {KeyboardAvoidingView} from 'react-native-keyboard-controller' // @ts-expect-error no type definition import ProgressCircle from 'react-native-progress/Circle' import Animated, { @@ -72,7 +72,6 @@ import { type SupportedMimeTypes, VIDEO_MAX_DURATION_MS, } from '#/lib/constants' -import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {createVideoTelemetry} from '#/lib/media/video/telemetry' import {mimeToExt} from '#/lib/media/video/util' @@ -287,7 +286,6 @@ export const ComposePost = ({ const {data: preferences} = usePreferencesQuery() const navigation = useNavigation() - const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isPublishing, setIsPublishing] = useState(false) const [publishingStage, setPublishingStage] = useState('') const [error, setError] = useState('') @@ -856,17 +854,9 @@ export const ComposePost = ({ const viewStyles = useMemo( () => ({ paddingTop: IS_ANDROID ? insets.top : 0, - paddingBottom: - // iOS - when keyboard is closed, keep the bottom bar in the safe area - (IS_IOS && !isKeyboardVisible) || - // Android - Android >=35 KeyboardAvoidingView adds double padding when - // keyboard is closed, so we subtract that in the offset and add it back - // here when the keyboard is open - (IS_ANDROID && isKeyboardVisible) - ? insets.bottom - : 0, + paddingBottom: insets.bottom, }), - [insets, isKeyboardVisible], + [insets.top, insets.bottom], ) const onPressCancel = useCallback(() => { @@ -1100,13 +1090,13 @@ export const ComposePost = ({ posts, } } - } catch (waitErr: any) { + } catch (waitErr) { logger.info(`composer: waiting for app view failed`, { safeMessage: waitErr, }) } - } catch (e: any) { - logger.error(e, { + } catch (e) { + logger.error(e instanceof Error ? e : String(e), { message: `Composer: create post failed`, hasImages: filteredThread.posts.some( p => @@ -1115,7 +1105,7 @@ export const ComposePost = ({ ), }) - let err = cleanError(e.message) + let err = e instanceof Error ? cleanError(e.message) : String(e) if ( e instanceof apilib.ReplyDeletedError || err.includes('not locate record') @@ -1417,8 +1407,8 @@ export const ComposePost = ({ publishingStage={publishingStage} topBarAnimatedStyle={topBarAnimatedStyle} onCancel={onPressCancel} - onPublish={onPressPublish} - onSelectDraft={handleSelectDraft} + onPublish={() => void onPressPublish()} + onSelectDraft={draft => void handleSelectDraft(draft)} onSaveDraft={saveCurrentDraft} onDiscard={handleClearComposer} isEmpty={isComposerEmpty} @@ -1531,7 +1521,7 @@ export const ComposePost = ({ {allPostsWithinLimit && ( void handleSaveDraft()} color="primary" /> )} @@ -1694,7 +1684,7 @@ let ComposerPost = memo(function ComposerPost({ postId: post.id, }) }} - onPhotoPasted={onPhotoPasted} + onPhotoPasted={uri => void onPhotoPasted(uri)} onNewLink={onNewLink} onError={onError} onPressPublish={onPublish} @@ -2188,7 +2178,7 @@ function ComposerFooter({ }), ).catch(e => { logger.error(`createComposerImage failed`, { - safeMessage: e.message, + safeMessage: e instanceof Error ? e.message : String(e), }) }) @@ -2425,24 +2415,31 @@ function useScrollTracker({ } function useKeyboardVerticalOffset() { - const {top, bottom} = useSafeAreaInsets() + const insets = useSafeAreaInsets() - // Android etc - if (!IS_IOS) { - // need to account for the edge-to-edge nav bar - return bottom * -1 + // the keyboardavoidingview has bottom padding to avoid being obscured by the safe area when keyboard is closed. + // however, this leads to a gap when the keyboard is open. we account for that by subtracting the bottom inset when open. + let keyboardVerticalOffset = insets.bottom * -1 + + // iOS requires a bit of extra offset to account for the native sheet not being at the top of the screen + if (IS_IOS) { + // they ditched the gap behaviour on 26 + if (IS_LIQUID_GLASS) { + keyboardVerticalOffset += insets.top + } + + // iPhone SE + else if (insets.top === 20) { + keyboardVerticalOffset += 40 + } + + // all other iPhones on <26 + else { + keyboardVerticalOffset += insets.top + 10 + } } - // they ditched the gap behaviour on 26 - if (IS_LIQUID_GLASS) { - return top - } - - // iPhone SE - if (top === 20) return 40 - - // all other iPhones on <26 - return top + 10 + return keyboardVerticalOffset } async function whenAppViewReady( diff --git a/src/view/com/composer/KeyboardAccessory.tsx b/src/view/com/composer/KeyboardAccessory.tsx deleted file mode 100644 index ae5a54275c..0000000000 --- a/src/view/com/composer/KeyboardAccessory.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import {View} from 'react-native' -import {KeyboardStickyView} from 'react-native-keyboard-controller' -import {useSafeAreaInsets} from 'react-native-safe-area-context' - -import {atoms as a, useTheme} from '#/alf' -import {IS_WEB} from '#/env' - -export function KeyboardAccessory({children}: {children: React.ReactNode}) { - const t = useTheme() - const {bottom} = useSafeAreaInsets() - - const style = [ - a.flex_row, - a.py_xs, - a.pl_sm, - a.pr_xl, - a.align_center, - a.border_t, - t.atoms.border_contrast_medium, - t.atoms.bg, - ] - - // todo: when iPad support is added, it should also not use the KeyboardStickyView - if (IS_WEB) { - return {children} - } - - return ( - - {children} - - ) -} diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 6737d1f628..492eac1c5d 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -288,6 +288,7 @@ export async function processVideo( dispatch({type: 'update_progress', progress: trunc2dp(num), signal}) }, signal, + onProbe: metadata => telemetry.probed(metadata), }) } catch (e) { const message = getCompressErrorMessage(e, i18n) diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx index 592266871e..b221b0fae3 100644 --- a/src/view/com/feeds/ComposerPrompt.tsx +++ b/src/view/com/feeds/ComposerPrompt.tsx @@ -163,7 +163,6 @@ export function ComposerPrompt() { }), web({ cursor: 'pointer', - outline: 'none', }), pressed && web({outline: 'none'}), ]}> diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx deleted file mode 100644 index 98d78a38cd..0000000000 --- a/src/view/com/util/ViewSelector.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { - forwardRef, - type JSX, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react' -import { - type NativeScrollEvent, - type NativeSyntheticEvent, - Pressable, - RefreshControl, - ScrollView, - StyleSheet, - View, -} from 'react-native' - -import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' -import {usePalette} from '#/lib/hooks/usePalette' -import {clamp} from '#/lib/numbers' -import {colors, s} from '#/lib/styles' -import {IS_ANDROID} from '#/env' -import {Text} from './text/Text' -import {FlatList_INTERNAL} from './Views' - -const HEADER_ITEM = {_reactKey: '__header__'} -const SELECTOR_ITEM = {_reactKey: '__selector__'} -const STICKY_HEADER_INDICES = [1] - -export type ViewSelectorHandle = { - scrollToTop: () => void -} - -export const ViewSelector = forwardRef< - ViewSelectorHandle, - { - sections: string[] - items: any[] - refreshing?: boolean - swipeEnabled?: boolean - renderHeader?: () => JSX.Element - renderItem: (item: any) => JSX.Element - ListFooterComponent?: - | React.ComponentType - | React.ReactElement - | null - | undefined - onSelectView?: (viewIndex: number) => void - onScroll?: (event: NativeSyntheticEvent) => void - onRefresh?: () => void - onEndReached?: (info: {distanceFromEnd: number}) => void - } ->(function ViewSelectorImpl( - { - sections, - items, - refreshing, - renderHeader, - renderItem, - ListFooterComponent, - onSelectView, - onScroll, - onRefresh, - onEndReached, - }, - ref, -) { - const pal = usePalette('default') - const [selectedIndex, setSelectedIndex] = useState(0) - const flatListRef = useRef(null) - - // events - // = - - const keyExtractor = useCallback((item: any) => item._reactKey, []) - - const onPressSelection = useCallback( - (index: number) => setSelectedIndex(clamp(index, 0, sections.length)), - [setSelectedIndex, sections], - ) - useEffect(() => { - onSelectView?.(selectedIndex) - }, [selectedIndex, onSelectView]) - - useImperativeHandle(ref, () => ({ - scrollToTop: () => { - flatListRef.current?.scrollToOffset({offset: 0}) - }, - })) - - // rendering - // = - - const renderItemInternal = useCallback( - ({item}: {item: any}) => { - if (item === HEADER_ITEM) { - if (renderHeader) { - return renderHeader() - } - return - } else if (item === SELECTOR_ITEM) { - return ( - - ) - } else { - return renderItem(item) - } - }, - [sections, selectedIndex, onPressSelection, renderHeader, renderItem], - ) - - const data = useMemo(() => [HEADER_ITEM, SELECTOR_ITEM, ...items], [items]) - return ( - - } - onEndReachedThreshold={0.6} - contentContainerStyle={s.contentContainer} - removeClippedSubviews={true} - scrollIndicatorInsets={{right: 1}} // fixes a bug where the scroll indicator is on the middle of the screen https://github.com/bluesky-social/social-app/pull/464 - /> - ) -}) - -export function Selector({ - selectedIndex, - items, - onSelect, -}: { - selectedIndex: number - items: string[] - onSelect?: (index: number) => void -}) { - const pal = usePalette('default') - const borderColor = useColorSchemeStyle( - {borderColor: colors.black}, - {borderColor: colors.white}, - ) - - const onPressItem = (index: number) => { - onSelect?.(index) - } - - return ( - - - - {items.map((item, i) => { - const selected = i === selectedIndex - return ( - onPressItem(i)} - accessibilityLabel={item} - accessibilityHint={`Selects ${item}`} - // TODO: Modify the component API such that lint fails - // at the invocation site as well - > - - - {item} - - - - ) - })} - - - - ) -} - -const styles = StyleSheet.create({ - outer: { - flexDirection: 'row', - paddingHorizontal: 14, - }, - item: { - marginRight: 14, - paddingHorizontal: 10, - paddingTop: 8, - paddingBottom: 12, - }, - itemSelected: { - borderBottomWidth: 3, - }, - label: { - fontWeight: '600', - }, - labelSelected: { - fontWeight: '600', - }, - underline: { - position: 'absolute', - height: 4, - bottom: 0, - }, -}) diff --git a/src/view/screens/Debug.tsx b/src/view/screens/Debug.tsx deleted file mode 100644 index bc31b586b3..0000000000 --- a/src/view/screens/Debug.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import {useState} from 'react' -import {ScrollView, View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' - -import {usePalette} from '#/lib/hooks/usePalette' -import { - type CommonNavigatorParams, - type NativeStackScreenProps, -} from '#/lib/routes/types' -import {s} from '#/lib/styles' -import {type PaletteColorName, ThemeProvider} from '#/lib/ThemeContext' -import {EmptyState} from '#/view/com/util/EmptyState' -import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' -import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' -import {Button} from '#/view/com/util/forms/Button' -import * as LoadingPlaceholder from '#/view/com/util/LoadingPlaceholder' -import {Text} from '#/view/com/util/text/Text' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {ViewSelector} from '#/view/com/util/ViewSelector' -import {HashtagWide_Stroke1_Corner0_Rounded as HashtagWideIcon} from '#/components/icons/Hashtag' -import * as Layout from '#/components/Layout' -import * as Toast from '#/components/Toast' - -const MAIN_VIEWS = ['Base', 'Controls', 'Error', 'Notifs'] - -export const DebugScreen = ({}: NativeStackScreenProps< - CommonNavigatorParams, - 'Debug' ->) => { - const [colorScheme, setColorScheme] = useState<'light' | 'dark'>('light') - const onToggleColorScheme = () => { - setColorScheme(colorScheme === 'light' ? 'dark' : 'light') - } - return ( - - - - - - ) -} - -function DebugInner({}: { - colorScheme: 'light' | 'dark' - onToggleColorScheme: () => void -}) { - const [currentView, setCurrentView] = useState(0) - const pal = usePalette('default') - const {_} = useLingui() - - const renderItem = (item: any) => { - return ( - - {item.currentView === 3 ? ( - - ) : item.currentView === 2 ? ( - - ) : item.currentView === 1 ? ( - - ) : ( - - )} - - ) - } - - const items = [{currentView}] - - return ( - - - - - ) -} - -function Heading({label}: {label: string}) { - const pal = usePalette('default') - return ( - - - {label} - - - ) -} - -function BaseView() { - return ( - - - - - - - - - - - - - - - - ) -} - -function ControlsView() { - return ( - - - - - - ) -} - -function ErrorView() { - return ( - - - {}} - /> - - - - - - - - - {}} - /> - - - {}} - numberOfLines={1} - /> - - - ) -} - -function NotifsView() { - const triggerPush = () => { - // TODO: implement local notification for testing - } - const triggerToast = () => { - Toast.show('The task has been completed') - } - const triggerToast2 = () => { - Toast.show('The task has been completed successfully and with no problems') - } - return ( - - -