Merge remote-tracking branch 'origin/main' into app-1715
* origin/main: (24 commits) Use RNKC for composer keyboard avoiding view (#10997) Fix yaml syntax in pull-request-commit.yml (#11037) Fix stale display names for lists/feeds in top bar across restarts/devices (#10651) Restore link in Go Live prompt if status record present (#10636) Added Automation label to labeller display names (#11027) Fix lint (#11038) Fix hashtag author filter failing for DIDs (#10772) Only request notification permission once per session (#11004) adding focus to the editing block (#10593) Add "fingerprint changed" label to PRs with native changes (#10742) Fix UI overlap issue where text was rendering over icons (#10774) Remove actions-up script in favor of Dependabot (#11034) Rename root file from `js` to `ts`, improve platform splitting (#11012) Route iOS notification settings intent into the app (#11003) Bump actions/checkout from 6.0.3 to 7.0.0 (#11022) Bump the actions group with 3 updates (#11021) Use latest fingerprint-native action (#11016) Avoid leaking previous messages via replies (#11017) Always set `allTime` to `true` for search v2 (#11033) Update search input with `useAutocomplete` (#11023) ...
This commit is contained in:
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup Docker buildx
|
- name: Setup Docker buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 5
|
fetch-depth: 5
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ jobs:
|
|||||||
- name: 📎 Attach APK to GitHub Release
|
- name: 📎 Attach APK to GitHub Release
|
||||||
id: attach
|
id: attach
|
||||||
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
if: ${{ steps.release-check.outputs.exists == 'true' }}
|
||||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
files: Bluesky-${{ needs.build.outputs.package-version }}.apk
|
files: Bluesky-${{ needs.build.outputs.package-version }}.apk
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 5
|
fetch-depth: 5
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
|
|
||||||
- name: 📷 Check fingerprint and install dependencies
|
- name: 📷 Check fingerprint and install dependencies
|
||||||
id: fingerprint
|
id: fingerprint
|
||||||
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
|
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.1.0
|
||||||
with:
|
with:
|
||||||
profile: ${{ inputs.channel || 'testflight' }}
|
profile: ${{ inputs.channel || 'testflight' }}
|
||||||
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
previous-commit-tag: ${{ inputs.runtimeVersion }}
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 5
|
fetch-depth: 5
|
||||||
|
|
||||||
@@ -329,7 +329,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 5
|
fetch-depth: 5
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
aws-region: us-east-2
|
aws-region: us-east-2
|
||||||
|
|
||||||
- name: Claude
|
- name: Claude
|
||||||
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
|
uses: anthropics/claude-code-action@30544b674398ee15c84819bd87caf8a87e8c7b55 # v1.0.154
|
||||||
with:
|
with:
|
||||||
use_bedrock: 'true'
|
use_bedrock: 'true'
|
||||||
additional_permissions: |
|
additional_permissions: |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
aws-region: us-east-2
|
aws-region: us-east-2
|
||||||
|
|
||||||
- name: Claude review
|
- name: Claude review
|
||||||
uses: anthropics/claude-code-action@9dd8b95a392eb34b6f5fb56cf5a64cb735912d4b # v1.0.150
|
uses: anthropics/claude-code-action@30544b674398ee15c84819bd87caf8a87e8c7b55 # v1.0.154
|
||||||
with:
|
with:
|
||||||
use_bedrock: 'true'
|
use_bedrock: 'true'
|
||||||
additional_permissions: |
|
additional_permissions: |
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Git Checkout
|
- name: Git Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Set up Go tooling
|
- name: Set up Go tooling
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Git Checkout
|
- name: Git Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Set up Go tooling
|
- name: Set up Go tooling
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
job: [lint, prettier, typecheck]
|
job: [lint, prettier, typecheck]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- name: Verify Node version pins match package.json
|
- name: Verify Node version pins match package.json
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
shard: [1, 2, 3, 4]
|
shard: [1, 2, 3, 4]
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
notes: ${{ steps.notes.outputs.notes }}
|
notes: ${{ steps.notes.outputs.notes }}
|
||||||
steps:
|
steps:
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
|
ssh-key: ${{secrets.GH_ACTION_DEPLOY_KEY}}
|
||||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ steps.pr-info.outputs.head-sha }}
|
ref: ${{ steps.pr-info.outputs.head-sha }}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: ⬇️ Checkout
|
- name: ⬇️ Checkout
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 100
|
fetch-depth: 100
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
|
|
||||||
- name: 📷 Check fingerprint and install dependencies
|
- name: 📷 Check fingerprint and install dependencies
|
||||||
id: fingerprint
|
id: fingerprint
|
||||||
uses: bluesky-social/github-actions/fingerprint-native@ebc6aa6d7466dc1e78b1e832041b7b81f6f95030 # v0.1.0
|
uses: bluesky-social/github-actions/fingerprint-native@b5556913e4aef3964cfd5936d0add3fc0d809bdb # v0.1.0
|
||||||
with:
|
with:
|
||||||
profile: pull-request
|
profile: pull-request
|
||||||
|
|
||||||
@@ -162,3 +162,19 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
header: fingerprint-diff
|
header: fingerprint-diff
|
||||||
delete: true
|
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
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
if: github.repository == 'bluesky-social/social-app'
|
if: github.repository == 'bluesky-social/social-app'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout public repo
|
- name: Checkout public repo
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
# Don't persist the checkout auth header; the push below authenticates
|
# Don't persist the checkout auth header; the push below authenticates
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out PR HEAD
|
- name: Check out PR HEAD
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out Git repository
|
- name: Check out Git repository
|
||||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||||
with:
|
with:
|
||||||
# Annotate the PR directly instead of uploading SARIF to the
|
# Annotate the PR directly instead of uploading SARIF to the
|
||||||
# security tab, and fail the check on any finding
|
# security tab, and fail the check on any finding
|
||||||
|
|||||||
@@ -18,19 +18,6 @@ module.exports = function (api) {
|
|||||||
plugins: [
|
plugins: [
|
||||||
'@lingui/babel-plugin-lingui-macro',
|
'@lingui/babel-plugin-lingui-macro',
|
||||||
['babel-plugin-react-compiler', {target: '19'}],
|
['babel-plugin-react-compiler', {target: '19'}],
|
||||||
[
|
|
||||||
'module:react-native-dotenv',
|
|
||||||
{
|
|
||||||
envName: 'APP_ENV',
|
|
||||||
moduleName: '@env',
|
|
||||||
path: '.env',
|
|
||||||
blocklist: null,
|
|
||||||
allowlist: null,
|
|
||||||
safe: false,
|
|
||||||
allowUndefined: true,
|
|
||||||
verbose: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'module-resolver',
|
'module-resolver',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -927,11 +927,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/lib/hooks/useTabFocusEffect.ts": {
|
|
||||||
"react-hooks/set-state-in-effect": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/lib/hooks/useToggleMutationQueue.ts": {
|
"src/lib/hooks/useToggleMutationQueue.ts": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -1073,17 +1068,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/screens/Bookmarks/index.tsx": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-misused-promises": {
|
|
||||||
"count": 3
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unsafe-member-access": {
|
|
||||||
"count": 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/screens/Deactivated.tsx": {
|
"src/screens/Deactivated.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -1956,15 +1940,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/view/com/composer/Composer.tsx": {
|
"src/view/com/composer/Composer.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-misused-promises": {
|
|
||||||
"count": 4
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unsafe-member-access": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"react-hooks/immutability": {
|
"react-hooks/immutability": {
|
||||||
"count": 2
|
"count": 2
|
||||||
},
|
},
|
||||||
@@ -2291,14 +2266,6 @@
|
|||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/view/com/util/ViewSelector.tsx": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 6
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unsafe-member-access": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/view/com/util/Views.tsx": {
|
"src/view/com/util/Views.tsx": {
|
||||||
"@typescript-eslint/no-explicit-any": {
|
"@typescript-eslint/no-explicit-any": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -2314,14 +2281,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/view/screens/Debug.tsx": {
|
|
||||||
"@typescript-eslint/no-explicit-any": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"@typescript-eslint/no-unsafe-member-access": {
|
|
||||||
"count": 4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/view/screens/Feeds.tsx": {
|
"src/view/screens/Feeds.tsx": {
|
||||||
"@typescript-eslint/no-floating-promises": {
|
"@typescript-eslint/no-floating-promises": {
|
||||||
"count": 3
|
"count": 3
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ This module consolidates several native features into a single Expo module:
|
|||||||
- **Referrer**: Tracking how users arrive at the app (web referrers, app referrers, Google Play install referrer)
|
- **Referrer**: Tracking how users arrive at the app (web referrers, app referrers, Google Play install referrer)
|
||||||
- **SharedPrefs**: Shared preferences storage using native platform APIs (UserDefaults on iOS, SharedPreferences on Android)
|
- **SharedPrefs**: Shared preferences storage using native platform APIs (UserDefaults on iOS, SharedPreferences on Android)
|
||||||
- **VisibilityView**: A native view component that tracks which view is currently visible on screen
|
- **VisibilityView**: A native view component that tracks which view is currently visible on screen
|
||||||
|
- **NotificationSettings**: iOS handler that routes the system "notification settings" intent into the app
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
@@ -123,9 +124,24 @@ This is useful for features like video autoplay, where you want to know which vi
|
|||||||
- Android: Full support using View position tracking
|
- Android: Full support using View position tracking
|
||||||
- Web: Passthrough component (renders children without tracking)
|
- Web: Passthrough component (renders children without tracking)
|
||||||
|
|
||||||
## Architecture
|
### NotificationSettings
|
||||||
|
|
||||||
### TypeScript Layer
|
iOS only. Has no JavaScript surface - it is a pure native side effect registered
|
||||||
|
at app launch.
|
||||||
|
|
||||||
|
When push permissions are requested with `provideAppNotificationSettings: true`,
|
||||||
|
iOS adds an in-app notification settings button to the system Settings screen for
|
||||||
|
Bluesky. Tapping it launches the app and triggers
|
||||||
|
`userNotificationCenter(_:openSettingsFor:)`. expo-notifications owns the
|
||||||
|
`UNUserNotificationCenter` delegate and fans this callback out to registered
|
||||||
|
`NotificationDelegate`s. This module registers one and converts the callback into
|
||||||
|
a `bluesky://settings/notifications` deep link, which the app's existing linking
|
||||||
|
config routes to the notification settings screen.
|
||||||
|
|
||||||
|
**Platform Support:**
|
||||||
|
- iOS: Full support
|
||||||
|
- Android: Not applicable (Android opens the system notification settings directly)
|
||||||
|
- Web: Not applicable
|
||||||
|
|
||||||
The module uses platform-specific file extensions to provide appropriate implementations:
|
The module uses platform-specific file extensions to provide appropriate implementations:
|
||||||
|
|
||||||
@@ -170,7 +186,9 @@ The module uses platform-specific file extensions to provide appropriate impleme
|
|||||||
|
|
||||||
### Expo Module Config
|
### Expo Module Config
|
||||||
|
|
||||||
The module is registered in `expo-module.config.json` with all four sub-modules for both iOS and Android.
|
The module is registered in `expo-module.config.json`. The PlatformInfo,
|
||||||
|
Referrer, SharedPrefs, and VisibilityView sub-modules are registered for both iOS
|
||||||
|
and Android; NotificationSettings is iOS only.
|
||||||
|
|
||||||
### iOS
|
### iOS
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"ExpoBlueskySharedPrefsModule",
|
"ExpoBlueskySharedPrefsModule",
|
||||||
"ExpoBlueskyReferrerModule",
|
"ExpoBlueskyReferrerModule",
|
||||||
"ExpoBlueskyVisibilityViewModule",
|
"ExpoBlueskyVisibilityViewModule",
|
||||||
"ExpoPlatformInfoModule"
|
"ExpoPlatformInfoModule",
|
||||||
|
"ExpoBlueskyNotificationSettingsModule"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Pod::Spec.new do |s|
|
|||||||
s.static_framework = true
|
s.static_framework = true
|
||||||
|
|
||||||
s.dependency 'ExpoModulesCore'
|
s.dependency 'ExpoModulesCore'
|
||||||
|
s.dependency 'EXNotifications'
|
||||||
|
|
||||||
# Swift/Objective-C compatibility
|
# Swift/Objective-C compatibility
|
||||||
s.pod_target_xcconfig = {
|
s.pod_target_xcconfig = {
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
import EXNotifications
|
||||||
|
import ExpoModulesCore
|
||||||
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When we request push notification permissions with
|
||||||
|
* `provideAppNotificationSettings: true`, iOS adds an in-app notification
|
||||||
|
* settings button to the system Settings screen for Bluesky (and may surface
|
||||||
|
* it elsewhere, e.g. from a delivered notification). Tapping it launches the
|
||||||
|
* app and calls `userNotificationCenter(_:openSettingsFor:)`.
|
||||||
|
*
|
||||||
|
* expo-notifications owns the `UNUserNotificationCenter` delegate via its
|
||||||
|
* `NotificationCenterManager`, which fans that callback out to any registered
|
||||||
|
* `NotificationDelegate` through `openSettings(_:)`. We register here and turn
|
||||||
|
* the callback into a `bluesky://settings/notifications` deep link so the app's
|
||||||
|
* existing linking config routes the user to the notification settings screen.
|
||||||
|
*/
|
||||||
|
public class ExpoBlueskyNotificationSettingsModule: Module, NotificationDelegate {
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyNotificationSettings")
|
||||||
|
|
||||||
|
OnCreate {
|
||||||
|
NotificationCenterManager.shared.addDelegate(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
OnDestroy {
|
||||||
|
NotificationCenterManager.shared.removeDelegate(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func openSettings(_ notification: UNNotification?) {
|
||||||
|
guard let url = URL(string: "bluesky://settings/notifications") else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
group = 'expo.modules.blueskyvideocompress'
|
||||||
|
version = '1.0.0'
|
||||||
|
|
||||||
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||||
|
apply from: expoModulesCorePlugin
|
||||||
|
applyKotlinExpoModulesCorePlugin()
|
||||||
|
useCoreDependencies()
|
||||||
|
useExpoPublishing()
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
ext.safeExtGet = { prop, fallback ->
|
||||||
|
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdkVersion safeExtGet("compileSdkVersion", 34)
|
||||||
|
|
||||||
|
namespace "expo.modules.blueskyvideocompress"
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||||
|
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
}
|
||||||
+55
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package expo.modules.blueskyvideocompress
|
||||||
|
|
||||||
|
import expo.modules.kotlin.modules.Module
|
||||||
|
import expo.modules.kotlin.modules.ModuleDefinition
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
class ExpoBlueskyVideoCompressModule : Module() {
|
||||||
|
private val activeCompressors = ConcurrentHashMap<Int, VideoCompressor>()
|
||||||
|
|
||||||
|
override fun definition() = ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyVideoCompress")
|
||||||
|
|
||||||
|
Events("onProgress")
|
||||||
|
|
||||||
|
AsyncFunction("probe") { uri: String ->
|
||||||
|
val context = appContext.reactContext
|
||||||
|
?: throw Error("React context is null")
|
||||||
|
return@AsyncFunction VideoProber.probe(context, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("compress") { uri: String, options: Map<String, Any?> ->
|
||||||
|
val context = appContext.reactContext
|
||||||
|
?: throw Error("React context is null")
|
||||||
|
val targetBitrate = (options["targetBitrate"] as? Number)?.toInt() ?: 0
|
||||||
|
val maxSize = (options["maxSize"] as? Number)?.toInt() ?: 1920
|
||||||
|
val codecPref = (options["codec"] as? String) ?: "auto"
|
||||||
|
val frameRateCap = ((options["frameRateCap"] as? Number)?.toInt() ?: 30).coerceAtLeast(1)
|
||||||
|
val jobId = (options["jobId"] as? Number)?.toInt() ?: 0
|
||||||
|
|
||||||
|
val compressor = VideoCompressor(
|
||||||
|
context = context,
|
||||||
|
uriString = uri,
|
||||||
|
targetBitrate = targetBitrate,
|
||||||
|
maxSize = maxSize,
|
||||||
|
codecPref = codecPref,
|
||||||
|
frameRateCap = frameRateCap,
|
||||||
|
jobId = jobId,
|
||||||
|
onProgress = { id, progress ->
|
||||||
|
sendEvent("onProgress", mapOf(
|
||||||
|
"id" to id,
|
||||||
|
"progress" to progress
|
||||||
|
))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
activeCompressors[jobId] = compressor
|
||||||
|
|
||||||
|
try {
|
||||||
|
val result = compressor.compress()
|
||||||
|
activeCompressors.remove(jobId)
|
||||||
|
return@AsyncFunction result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
activeCompressors.remove(jobId)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancel") { jobId: Int ->
|
||||||
|
activeCompressors.remove(jobId)?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
package expo.modules.blueskyvideocompress
|
||||||
|
|
||||||
|
import android.opengl.EGL14
|
||||||
|
import android.opengl.EGLConfig
|
||||||
|
import android.opengl.EGLContext
|
||||||
|
import android.opengl.EGLDisplay
|
||||||
|
import android.opengl.EGLExt
|
||||||
|
import android.opengl.EGLSurface
|
||||||
|
import android.view.Surface
|
||||||
|
|
||||||
|
class InputSurface(private val surface: Surface) {
|
||||||
|
private var eglDisplay: EGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||||
|
private var eglContext: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||||
|
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||||
|
|
||||||
|
init {
|
||||||
|
eglSetup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun eglSetup() {
|
||||||
|
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||||
|
if (eglDisplay === EGL14.EGL_NO_DISPLAY) {
|
||||||
|
throw RuntimeException("unable to get EGL14 display")
|
||||||
|
}
|
||||||
|
|
||||||
|
val version = IntArray(2)
|
||||||
|
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
|
||||||
|
throw RuntimeException("unable to initialize EGL14")
|
||||||
|
}
|
||||||
|
|
||||||
|
val attribList = intArrayOf(
|
||||||
|
EGL14.EGL_RED_SIZE, 8,
|
||||||
|
EGL14.EGL_GREEN_SIZE, 8,
|
||||||
|
EGL14.EGL_BLUE_SIZE, 8,
|
||||||
|
EGL14.EGL_ALPHA_SIZE, 8,
|
||||||
|
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||||
|
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
|
||||||
|
EGL14.EGL_NONE
|
||||||
|
)
|
||||||
|
val configs = arrayOfNulls<EGLConfig>(1)
|
||||||
|
val numConfigs = IntArray(1)
|
||||||
|
EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, 1, numConfigs, 0)
|
||||||
|
checkEglError("eglChooseConfig")
|
||||||
|
|
||||||
|
val contextAttribs = intArrayOf(
|
||||||
|
EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
|
||||||
|
EGL14.EGL_NONE
|
||||||
|
)
|
||||||
|
eglContext = EGL14.eglCreateContext(
|
||||||
|
eglDisplay, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0
|
||||||
|
)
|
||||||
|
checkEglError("eglCreateContext")
|
||||||
|
|
||||||
|
val surfaceAttribs = intArrayOf(EGL14.EGL_NONE)
|
||||||
|
eglSurface = EGL14.eglCreateWindowSurface(
|
||||||
|
eglDisplay, configs[0], surface, surfaceAttribs, 0
|
||||||
|
)
|
||||||
|
checkEglError("eglCreateWindowSurface")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun makeCurrent() {
|
||||||
|
EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)
|
||||||
|
checkEglError("eglMakeCurrent")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun swapBuffers(): Boolean {
|
||||||
|
return EGL14.eglSwapBuffers(eglDisplay, eglSurface)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPresentationTime(nsecs: Long) {
|
||||||
|
EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, nsecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release() {
|
||||||
|
if (eglDisplay !== EGL14.EGL_NO_DISPLAY) {
|
||||||
|
EGL14.eglMakeCurrent(
|
||||||
|
eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT
|
||||||
|
)
|
||||||
|
EGL14.eglDestroySurface(eglDisplay, eglSurface)
|
||||||
|
EGL14.eglDestroyContext(eglDisplay, eglContext)
|
||||||
|
EGL14.eglReleaseThread()
|
||||||
|
EGL14.eglTerminate(eglDisplay)
|
||||||
|
}
|
||||||
|
surface.release()
|
||||||
|
eglDisplay = EGL14.EGL_NO_DISPLAY
|
||||||
|
eglContext = EGL14.EGL_NO_CONTEXT
|
||||||
|
eglSurface = EGL14.EGL_NO_SURFACE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkEglError(msg: String) {
|
||||||
|
val error = EGL14.eglGetError()
|
||||||
|
if (error != EGL14.EGL_SUCCESS) {
|
||||||
|
throw RuntimeException("$msg: EGL error: 0x${Integer.toHexString(error)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+582
@@ -0,0 +1,582 @@
|
|||||||
|
package expo.modules.blueskyvideocompress
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.MediaMuxer
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class VideoCompressor(
|
||||||
|
private val context: Context,
|
||||||
|
private val uriString: String,
|
||||||
|
private val targetBitrate: Int,
|
||||||
|
private val maxSize: Int,
|
||||||
|
private val codecPref: String,
|
||||||
|
private val frameRateCap: Int,
|
||||||
|
private val jobId: Int,
|
||||||
|
private val onProgress: (Int, Double) -> Unit
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BskyVideoCompress"
|
||||||
|
private const val TIMEOUT_DEQUEUE = 100L
|
||||||
|
private const val I_FRAME_INTERVAL = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isCancelled = false
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun compress(): Map<String, Any> {
|
||||||
|
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
|
||||||
|
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
|
||||||
|
val tryHevc = codecPref == "hevc"
|
||||||
|
|
||||||
|
if (tryHevc) {
|
||||||
|
try {
|
||||||
|
return doCompress(useHevc = true, allowSoftwareFallback = false)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (codecPref == "hevc" || isCancelled) throw e
|
||||||
|
Log.w(TAG, "HEVC encode failed, falling back to h264", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doCompress(useHevc = false, allowSoftwareFallback = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doCompress(useHevc: Boolean, allowSoftwareFallback: Boolean): Map<String, Any> {
|
||||||
|
val mime = if (useHevc) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC
|
||||||
|
val encoderInfo = CodecSelector.findEncoder(mime, requireHardware = !allowSoftwareFallback)
|
||||||
|
?: throw RuntimeException("No encoder for $mime")
|
||||||
|
|
||||||
|
try {
|
||||||
|
return runPipeline(encoderInfo, useHevc)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (!allowSoftwareFallback || isCancelled || !encoderInfo.isHardware) throw e
|
||||||
|
Log.w(TAG, "Hardware encoder ${encoderInfo.name} failed, trying software", e)
|
||||||
|
val sw = CodecSelector.findEncoder(mime, requireHardware = false)
|
||||||
|
?.takeIf { !it.isHardware }
|
||||||
|
?: throw e
|
||||||
|
return runPipeline(sw, useHevc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runPipeline(
|
||||||
|
encoderInfo: CodecSelector.EncoderInfo,
|
||||||
|
useHevc: Boolean
|
||||||
|
): Map<String, Any> {
|
||||||
|
val outputFile = File(context.cacheDir, "${UUID.randomUUID()}.mp4")
|
||||||
|
|
||||||
|
var extractor: MediaExtractor? = null
|
||||||
|
var muxer: MediaMuxer? = null
|
||||||
|
var encoder: MediaCodec? = null
|
||||||
|
var decoder: MediaCodec? = null
|
||||||
|
var inputSurface: InputSurface? = null
|
||||||
|
var outputSurface: OutputSurface? = null
|
||||||
|
var muxerStarted = false
|
||||||
|
var outputDims = Pair(0, 0)
|
||||||
|
var durationUs = 0L
|
||||||
|
|
||||||
|
try {
|
||||||
|
val uri = Uri.parse(uriString)
|
||||||
|
extractor = MediaExtractor()
|
||||||
|
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||||
|
extractor.setDataSource(context, uri, null)
|
||||||
|
} else {
|
||||||
|
extractor.setDataSource(uriString)
|
||||||
|
}
|
||||||
|
|
||||||
|
var videoTrackIndex = -1
|
||||||
|
var audioTrackIndex = -1
|
||||||
|
var videoFormat: MediaFormat? = null
|
||||||
|
var audioFormat: MediaFormat? = null
|
||||||
|
|
||||||
|
for (i in 0 until extractor.trackCount) {
|
||||||
|
val format = extractor.getTrackFormat(i)
|
||||||
|
val trackMime = format.getString(MediaFormat.KEY_MIME) ?: continue
|
||||||
|
if (trackMime.startsWith("video/") && videoTrackIndex == -1) {
|
||||||
|
videoTrackIndex = i
|
||||||
|
videoFormat = format
|
||||||
|
} else if (trackMime.startsWith("audio/") && audioTrackIndex == -1) {
|
||||||
|
audioTrackIndex = i
|
||||||
|
audioFormat = format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoTrackIndex == -1 || videoFormat == null) {
|
||||||
|
throw RuntimeException("No video track found")
|
||||||
|
}
|
||||||
|
|
||||||
|
val sourceWidth = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
|
||||||
|
val sourceHeight = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
|
||||||
|
val rotation = if (videoFormat.containsKey(MediaFormat.KEY_ROTATION)) {
|
||||||
|
videoFormat.getInteger(MediaFormat.KEY_ROTATION)
|
||||||
|
} else 0
|
||||||
|
durationUs = if (videoFormat.containsKey(MediaFormat.KEY_DURATION)) {
|
||||||
|
videoFormat.getLong(MediaFormat.KEY_DURATION)
|
||||||
|
} else 0L
|
||||||
|
val sourceFps = if (videoFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
|
||||||
|
videoFormat.getInteger(MediaFormat.KEY_FRAME_RATE)
|
||||||
|
} else 30
|
||||||
|
|
||||||
|
outputDims = calculateOutputDims(sourceWidth, sourceHeight, rotation, maxSize)
|
||||||
|
val shouldPassthroughAudio = audioFormat != null && canPassthroughAudio(audioFormat)
|
||||||
|
val transcodedAudio: TranscodedAudio? = if (
|
||||||
|
audioTrackIndex >= 0 && audioFormat != null && !shouldPassthroughAudio
|
||||||
|
) {
|
||||||
|
transcodeAudioToAAC(audioTrackIndex, audioFormat)
|
||||||
|
} else null
|
||||||
|
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||||
|
|
||||||
|
val effectiveBitrate = if (targetBitrate > 0) {
|
||||||
|
targetBitrate
|
||||||
|
} else if (useHevc) 2_500_000 else 3_000_000
|
||||||
|
|
||||||
|
val encoderFormat = MediaFormat.createVideoFormat(
|
||||||
|
encoderInfo.mime, outputDims.first, outputDims.second
|
||||||
|
).apply {
|
||||||
|
setInteger(
|
||||||
|
MediaFormat.KEY_COLOR_FORMAT,
|
||||||
|
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||||
|
)
|
||||||
|
setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate)
|
||||||
|
setInteger(
|
||||||
|
MediaFormat.KEY_BITRATE_MODE,
|
||||||
|
MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR
|
||||||
|
)
|
||||||
|
setInteger(MediaFormat.KEY_FRAME_RATE, frameRateCap)
|
||||||
|
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
setInteger(MediaFormat.KEY_COLOR_STANDARD, MediaFormat.COLOR_STANDARD_BT709)
|
||||||
|
setInteger(MediaFormat.KEY_COLOR_TRANSFER, MediaFormat.COLOR_TRANSFER_SDR_VIDEO)
|
||||||
|
setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
setInteger(MediaFormat.KEY_PRIORITY, 0)
|
||||||
|
setInteger(MediaFormat.KEY_OPERATING_RATE, frameRateCap)
|
||||||
|
if (useHevc) {
|
||||||
|
setInteger(
|
||||||
|
MediaFormat.KEY_PROFILE,
|
||||||
|
MediaCodecInfo.CodecProfileLevel.HEVCProfileMain
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setInteger(
|
||||||
|
MediaFormat.KEY_PROFILE,
|
||||||
|
MediaCodecInfo.CodecProfileLevel.AVCProfileHigh
|
||||||
|
)
|
||||||
|
setInteger(
|
||||||
|
MediaFormat.KEY_LEVEL,
|
||||||
|
MediaCodecInfo.CodecProfileLevel.AVCLevel41
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encoder = MediaCodec.createByCodecName(encoderInfo.name)
|
||||||
|
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
val encoderInputSurface = encoder.createInputSurface()
|
||||||
|
inputSurface = InputSurface(encoderInputSurface)
|
||||||
|
inputSurface.makeCurrent()
|
||||||
|
outputSurface = OutputSurface()
|
||||||
|
encoder.start()
|
||||||
|
|
||||||
|
decoder = MediaCodec.createDecoderByType(
|
||||||
|
videoFormat.getString(MediaFormat.KEY_MIME) ?: "video/avc"
|
||||||
|
)
|
||||||
|
// Ask the decoder to tone-map HDR (HLG/PQ) sources to SDR. Vendors may
|
||||||
|
// ignore the hint, but where supported it produces correct BT.709 pixels
|
||||||
|
// for the encoder rather than HDR pixels mislabeled as SDR.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
videoFormat.setInteger(
|
||||||
|
MediaFormat.KEY_COLOR_TRANSFER_REQUEST,
|
||||||
|
MediaFormat.COLOR_TRANSFER_SDR_VIDEO
|
||||||
|
)
|
||||||
|
}
|
||||||
|
decoder.configure(videoFormat, outputSurface.surface, null, 0)
|
||||||
|
decoder.start()
|
||||||
|
extractor.selectTrack(videoTrackIndex)
|
||||||
|
|
||||||
|
val frameDropEnabled = sourceFps > frameRateCap
|
||||||
|
val targetFrameIntervalUs = if (frameDropEnabled) 1_000_000L / frameRateCap else 0L
|
||||||
|
var nextTargetPtsUs = 0L
|
||||||
|
|
||||||
|
var muxerVideoTrack = -1
|
||||||
|
var muxerAudioTrack = -1
|
||||||
|
|
||||||
|
val bufferInfo = MediaCodec.BufferInfo()
|
||||||
|
var inputDone = false
|
||||||
|
var decoderDone = false
|
||||||
|
var outputDone = false
|
||||||
|
var lastProgressMs = 0L
|
||||||
|
|
||||||
|
while (!outputDone && !isCancelled) {
|
||||||
|
if (!inputDone) {
|
||||||
|
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||||
|
if (idx >= 0) {
|
||||||
|
val buf = decoder.getInputBuffer(idx)
|
||||||
|
if (buf != null) {
|
||||||
|
val sz = extractor.readSampleData(buf, 0)
|
||||||
|
if (sz < 0) {
|
||||||
|
decoder.queueInputBuffer(
|
||||||
|
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||||
|
)
|
||||||
|
inputDone = true
|
||||||
|
} else {
|
||||||
|
decoder.queueInputBuffer(idx, 0, sz, extractor.sampleTime, 0)
|
||||||
|
extractor.advance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decoderDone) {
|
||||||
|
val status = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_DEQUEUE)
|
||||||
|
if (status >= 0) {
|
||||||
|
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||||
|
val shouldRender = if (isEos) {
|
||||||
|
false
|
||||||
|
} else if (frameDropEnabled) {
|
||||||
|
if (bufferInfo.presentationTimeUs >= nextTargetPtsUs) {
|
||||||
|
nextTargetPtsUs = bufferInfo.presentationTimeUs + targetFrameIntervalUs
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
} else true
|
||||||
|
decoder.releaseOutputBuffer(status, shouldRender)
|
||||||
|
if (shouldRender) {
|
||||||
|
outputSurface.awaitNewImage()
|
||||||
|
outputSurface.drawImage()
|
||||||
|
inputSurface.setPresentationTime(bufferInfo.presentationTimeUs * 1000)
|
||||||
|
inputSurface.swapBuffers()
|
||||||
|
}
|
||||||
|
if (isEos) {
|
||||||
|
encoder.signalEndOfInputStream()
|
||||||
|
decoderDone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoderDrained = false
|
||||||
|
while (!outputDone && !isCancelled && !encoderDrained) {
|
||||||
|
val encIdx = encoder.dequeueOutputBuffer(bufferInfo, 0)
|
||||||
|
when {
|
||||||
|
encIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> encoderDrained = true
|
||||||
|
encIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||||
|
if (!muxerStarted) {
|
||||||
|
muxerVideoTrack = muxer.addTrack(encoder.outputFormat)
|
||||||
|
if (audioTrackIndex >= 0 && audioFormat != null) {
|
||||||
|
if (shouldPassthroughAudio) {
|
||||||
|
muxerAudioTrack = muxer.addTrack(audioFormat)
|
||||||
|
} else if (transcodedAudio != null) {
|
||||||
|
muxerAudioTrack = muxer.addTrack(transcodedAudio.outputFormat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
muxer.start()
|
||||||
|
muxerStarted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encIdx >= 0 -> {
|
||||||
|
val data = encoder.getOutputBuffer(encIdx)
|
||||||
|
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
|
||||||
|
bufferInfo.size = 0
|
||||||
|
}
|
||||||
|
if (data != null && bufferInfo.size > 0 && muxerStarted) {
|
||||||
|
muxer.writeSampleData(muxerVideoTrack, data, bufferInfo)
|
||||||
|
}
|
||||||
|
val isEos = bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||||
|
encoder.releaseOutputBuffer(encIdx, false)
|
||||||
|
if (isEos) {
|
||||||
|
outputDone = true
|
||||||
|
} else if (durationUs > 0) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastProgressMs >= 100) {
|
||||||
|
lastProgressMs = now
|
||||||
|
val p = (bufferInfo.presentationTimeUs.toDouble() / durationUs)
|
||||||
|
.coerceIn(0.0, 1.0)
|
||||||
|
onProgress(jobId, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioTrackIndex >= 0 && muxerAudioTrack >= 0 && muxerStarted && !isCancelled) {
|
||||||
|
if (shouldPassthroughAudio) {
|
||||||
|
passthroughAudio(audioTrackIndex, muxer, muxerAudioTrack)
|
||||||
|
} else if (transcodedAudio != null) {
|
||||||
|
writeTranscodedAudio(transcodedAudio.samples, muxer, muxerAudioTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { decoder?.stop() } catch (_: Exception) {}
|
||||||
|
try { decoder?.release() } catch (_: Exception) {}
|
||||||
|
try { encoder?.stop() } catch (_: Exception) {}
|
||||||
|
try { encoder?.release() } catch (_: Exception) {}
|
||||||
|
try { outputSurface?.release() } catch (_: Exception) {}
|
||||||
|
try { inputSurface?.release() } catch (_: Exception) {}
|
||||||
|
try { extractor?.release() } catch (_: Exception) {}
|
||||||
|
try {
|
||||||
|
if (muxerStarted) muxer?.stop()
|
||||||
|
muxer?.release()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
outputFile.delete()
|
||||||
|
throw RuntimeException("Compression cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(jobId, 1.0)
|
||||||
|
val durationSeconds = durationUs / 1_000_000.0
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"uri" to "file://${outputFile.absolutePath}",
|
||||||
|
"size" to outputFile.length(),
|
||||||
|
"mimeType" to "video/mp4",
|
||||||
|
"width" to outputDims.first,
|
||||||
|
"height" to outputDims.second,
|
||||||
|
"duration" to durationSeconds,
|
||||||
|
"codec" to if (useHevc) "hevc" else "h264"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun passthroughAudio(audioTrackIndex: Int, muxer: MediaMuxer, muxerAudioTrack: Int) {
|
||||||
|
val audioExtractor = MediaExtractor()
|
||||||
|
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||||
|
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
|
||||||
|
} else {
|
||||||
|
audioExtractor.setDataSource(uriString)
|
||||||
|
}
|
||||||
|
audioExtractor.selectTrack(audioTrackIndex)
|
||||||
|
audioExtractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
|
||||||
|
|
||||||
|
val buffer = ByteBuffer.allocate(256 * 1024)
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
try {
|
||||||
|
while (!isCancelled) {
|
||||||
|
val sz = audioExtractor.readSampleData(buffer, 0)
|
||||||
|
if (sz < 0) break
|
||||||
|
info.offset = 0
|
||||||
|
info.size = sz
|
||||||
|
info.presentationTimeUs = audioExtractor.sampleTime
|
||||||
|
info.flags = audioExtractor.sampleFlags
|
||||||
|
muxer.writeSampleData(muxerAudioTrack, buffer, info)
|
||||||
|
audioExtractor.advance()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
audioExtractor.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canPassthroughAudio(format: MediaFormat): Boolean {
|
||||||
|
val mime = format.getString(MediaFormat.KEY_MIME) ?: return false
|
||||||
|
return mime == MediaFormat.MIMETYPE_AUDIO_AAC
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class TranscodedAudio(
|
||||||
|
val outputFormat: MediaFormat,
|
||||||
|
val samples: List<Sample>
|
||||||
|
) {
|
||||||
|
data class Sample(
|
||||||
|
val bytes: ByteArray,
|
||||||
|
val presentationTimeUs: Long,
|
||||||
|
val flags: Int
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-encode non-AAC source audio (Opus, Vorbis, etc.) to AAC so the mp4 muxer
|
||||||
|
// can take it. iOS always re-encodes to AAC; without this Android would drop
|
||||||
|
// the audio track entirely.
|
||||||
|
private fun transcodeAudioToAAC(
|
||||||
|
audioTrackIndex: Int,
|
||||||
|
sourceFormat: MediaFormat
|
||||||
|
): TranscodedAudio? {
|
||||||
|
val sourceMime = sourceFormat.getString(MediaFormat.KEY_MIME) ?: return null
|
||||||
|
val sampleRate = if (sourceFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE))
|
||||||
|
sourceFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) else 44100
|
||||||
|
val channelCount = if (sourceFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT))
|
||||||
|
sourceFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT).coerceIn(1, 2) else 2
|
||||||
|
|
||||||
|
val audioExtractor = MediaExtractor()
|
||||||
|
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||||
|
audioExtractor.setDataSource(context, Uri.parse(uriString), null)
|
||||||
|
} else {
|
||||||
|
audioExtractor.setDataSource(uriString)
|
||||||
|
}
|
||||||
|
audioExtractor.selectTrack(audioTrackIndex)
|
||||||
|
|
||||||
|
var decoder: MediaCodec? = null
|
||||||
|
var encoder: MediaCodec? = null
|
||||||
|
try {
|
||||||
|
decoder = MediaCodec.createDecoderByType(sourceMime)
|
||||||
|
decoder.configure(sourceFormat, null, null, 0)
|
||||||
|
decoder.start()
|
||||||
|
|
||||||
|
val encoderFormat = MediaFormat.createAudioFormat(
|
||||||
|
MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channelCount
|
||||||
|
).apply {
|
||||||
|
setInteger(
|
||||||
|
MediaFormat.KEY_AAC_PROFILE,
|
||||||
|
MediaCodecInfo.CodecProfileLevel.AACObjectLC
|
||||||
|
)
|
||||||
|
setInteger(MediaFormat.KEY_BIT_RATE, 128_000)
|
||||||
|
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 256 * 1024)
|
||||||
|
}
|
||||||
|
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC)
|
||||||
|
encoder.configure(encoderFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
encoder.start()
|
||||||
|
|
||||||
|
val samples = mutableListOf<TranscodedAudio.Sample>()
|
||||||
|
var outputFormat: MediaFormat? = null
|
||||||
|
var inputDone = false
|
||||||
|
var decoderDone = false
|
||||||
|
var encoderInputSignalled = false
|
||||||
|
var encoderDone = false
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
|
||||||
|
while (!encoderDone && !isCancelled) {
|
||||||
|
if (!inputDone) {
|
||||||
|
val idx = decoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||||
|
if (idx >= 0) {
|
||||||
|
val buf = decoder.getInputBuffer(idx)
|
||||||
|
if (buf != null) {
|
||||||
|
val sz = audioExtractor.readSampleData(buf, 0)
|
||||||
|
if (sz < 0) {
|
||||||
|
decoder.queueInputBuffer(
|
||||||
|
idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||||
|
)
|
||||||
|
inputDone = true
|
||||||
|
} else {
|
||||||
|
decoder.queueInputBuffer(
|
||||||
|
idx, 0, sz, audioExtractor.sampleTime, audioExtractor.sampleFlags
|
||||||
|
)
|
||||||
|
audioExtractor.advance()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decoderDone) {
|
||||||
|
val status = decoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
|
||||||
|
when {
|
||||||
|
status == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
|
||||||
|
status == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {}
|
||||||
|
status >= 0 -> {
|
||||||
|
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||||
|
val data = decoder.getOutputBuffer(status)
|
||||||
|
if (data != null && info.size > 0) {
|
||||||
|
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE)
|
||||||
|
if (encInIdx >= 0) {
|
||||||
|
val encInBuf = encoder.getInputBuffer(encInIdx)
|
||||||
|
if (encInBuf != null) {
|
||||||
|
encInBuf.clear()
|
||||||
|
data.position(info.offset)
|
||||||
|
data.limit(info.offset + info.size)
|
||||||
|
encInBuf.put(data)
|
||||||
|
encoder.queueInputBuffer(
|
||||||
|
encInIdx, 0, info.size, info.presentationTimeUs, 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decoder.releaseOutputBuffer(status, false)
|
||||||
|
if (isEos) {
|
||||||
|
if (!encoderInputSignalled) {
|
||||||
|
val encInIdx = encoder.dequeueInputBuffer(TIMEOUT_DEQUEUE * 10)
|
||||||
|
if (encInIdx >= 0) {
|
||||||
|
encoder.queueInputBuffer(
|
||||||
|
encInIdx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||||
|
)
|
||||||
|
encoderInputSignalled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decoderDone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val encOutIdx = encoder.dequeueOutputBuffer(info, TIMEOUT_DEQUEUE)
|
||||||
|
when {
|
||||||
|
encOutIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> {}
|
||||||
|
encOutIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||||
|
outputFormat = encoder.outputFormat
|
||||||
|
}
|
||||||
|
encOutIdx >= 0 -> {
|
||||||
|
val data = encoder.getOutputBuffer(encOutIdx)
|
||||||
|
val isEos = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||||
|
val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
|
||||||
|
if (data != null && info.size > 0 && !isConfig) {
|
||||||
|
val bytes = ByteArray(info.size)
|
||||||
|
data.position(info.offset)
|
||||||
|
data.get(bytes, 0, info.size)
|
||||||
|
samples.add(
|
||||||
|
TranscodedAudio.Sample(
|
||||||
|
bytes = bytes,
|
||||||
|
presentationTimeUs = info.presentationTimeUs,
|
||||||
|
flags = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG.inv()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
encoder.releaseOutputBuffer(encOutIdx, false)
|
||||||
|
if (isEos) encoderDone = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fmt = outputFormat ?: return null
|
||||||
|
return TranscodedAudio(fmt, samples)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Audio transcode failed; dropping audio", e)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
try { decoder?.stop() } catch (_: Exception) {}
|
||||||
|
try { decoder?.release() } catch (_: Exception) {}
|
||||||
|
try { encoder?.stop() } catch (_: Exception) {}
|
||||||
|
try { encoder?.release() } catch (_: Exception) {}
|
||||||
|
audioExtractor.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeTranscodedAudio(
|
||||||
|
samples: List<TranscodedAudio.Sample>,
|
||||||
|
muxer: MediaMuxer,
|
||||||
|
muxerAudioTrack: Int
|
||||||
|
) {
|
||||||
|
val info = MediaCodec.BufferInfo()
|
||||||
|
for (sample in samples) {
|
||||||
|
if (isCancelled) break
|
||||||
|
val buffer = ByteBuffer.wrap(sample.bytes)
|
||||||
|
info.offset = 0
|
||||||
|
info.size = sample.bytes.size
|
||||||
|
info.presentationTimeUs = sample.presentationTimeUs
|
||||||
|
info.flags = sample.flags
|
||||||
|
muxer.writeSampleData(muxerAudioTrack, buffer, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateOutputDims(srcW: Int, srcH: Int, rotation: Int, maxSize: Int): Pair<Int, Int> {
|
||||||
|
val isRotated = rotation == 90 || rotation == 270
|
||||||
|
val displayW = if (isRotated) srcH else srcW
|
||||||
|
val displayH = if (isRotated) srcW else srcH
|
||||||
|
val scale: Float = when {
|
||||||
|
displayW <= maxSize && displayH <= maxSize -> 1.0f
|
||||||
|
displayW > displayH -> maxSize.toFloat() / displayW
|
||||||
|
else -> maxSize.toFloat() / displayH
|
||||||
|
}
|
||||||
|
return Pair(
|
||||||
|
roundToEven((displayW * scale).toInt()),
|
||||||
|
roundToEven((displayH * scale).toInt())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun roundToEven(v: Int): Int = if (v % 2 == 0) v else v - 1
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
package expo.modules.blueskyvideocompress
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaExtractor
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.MediaMetadataRetriever
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
object VideoProber {
|
||||||
|
fun probe(context: Context, uriString: String): Map<String, Any> {
|
||||||
|
val uri = Uri.parse(uriString)
|
||||||
|
val retriever = MediaMetadataRetriever()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||||
|
retriever.setDataSource(context, uri)
|
||||||
|
} else {
|
||||||
|
retriever.setDataSource(uriString)
|
||||||
|
}
|
||||||
|
|
||||||
|
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||||
|
?.toIntOrNull() ?: 0
|
||||||
|
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||||
|
?.toIntOrNull() ?: 0
|
||||||
|
val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||||
|
?.toLongOrNull() ?: 0L
|
||||||
|
val rotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||||
|
?.toIntOrNull() ?: 0
|
||||||
|
val bitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE)
|
||||||
|
?.toIntOrNull() ?: 0
|
||||||
|
val hasAudio = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
|
||||||
|
?.equals("yes") ?: false
|
||||||
|
val frameRate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE)
|
||||||
|
?.toFloatOrNull() ?: 0f
|
||||||
|
|
||||||
|
val fileSize = getFileSize(context, uriString)
|
||||||
|
|
||||||
|
val extractor = MediaExtractor()
|
||||||
|
var codec = "unknown"
|
||||||
|
var mimeType = "video/mp4"
|
||||||
|
var extractedFrameRate = frameRate
|
||||||
|
var isHDR = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (uriString.startsWith("content://") || uriString.startsWith("file://")) {
|
||||||
|
extractor.setDataSource(context, uri, null)
|
||||||
|
} else {
|
||||||
|
extractor.setDataSource(uriString)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in 0 until extractor.trackCount) {
|
||||||
|
val format = extractor.getTrackFormat(i)
|
||||||
|
val mime = format.getString(MediaFormat.KEY_MIME)
|
||||||
|
if (mime?.startsWith("video/") == true) {
|
||||||
|
mimeType = mime
|
||||||
|
codec = mime.removePrefix("video/")
|
||||||
|
if (format.containsKey(MediaFormat.KEY_FRAME_RATE)) {
|
||||||
|
extractedFrameRate = format.getInteger(MediaFormat.KEY_FRAME_RATE).toFloat()
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||||
|
format.containsKey(MediaFormat.KEY_COLOR_TRANSFER)
|
||||||
|
) {
|
||||||
|
val transfer = format.getInteger(MediaFormat.KEY_COLOR_TRANSFER)
|
||||||
|
isHDR = transfer == MediaFormat.COLOR_TRANSFER_HLG ||
|
||||||
|
transfer == MediaFormat.COLOR_TRANSFER_ST2084
|
||||||
|
}
|
||||||
|
// Dolby Vision tracks use codec-specific mimes that aren't covered by
|
||||||
|
// KEY_COLOR_TRANSFER on every device.
|
||||||
|
if (mime.contains("dolby-vision", ignoreCase = true)) {
|
||||||
|
isHDR = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
extractor.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
val durationSeconds = durationMs / 1000.0
|
||||||
|
val effectiveBitrate = if (bitrate > 0) {
|
||||||
|
bitrate
|
||||||
|
} else if (durationSeconds > 0 && fileSize > 0) {
|
||||||
|
(fileSize * 8 / durationSeconds).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"width" to width,
|
||||||
|
"height" to height,
|
||||||
|
"duration" to durationSeconds,
|
||||||
|
"bitrate" to effectiveBitrate,
|
||||||
|
"fileSize" to fileSize,
|
||||||
|
"mimeType" to mimeType,
|
||||||
|
"codec" to codec,
|
||||||
|
"hasAudio" to hasAudio,
|
||||||
|
"frameRate" to extractedFrameRate.toDouble(),
|
||||||
|
"rotation" to rotation,
|
||||||
|
"isHDR" to isHDR
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
retriever.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFileSize(context: Context, uriString: String): Long {
|
||||||
|
return try {
|
||||||
|
if (uriString.startsWith("content://")) {
|
||||||
|
context.contentResolver.openFileDescriptor(Uri.parse(uriString), "r")?.use {
|
||||||
|
it.statSize
|
||||||
|
} ?: 0L
|
||||||
|
} else {
|
||||||
|
val path = if (uriString.startsWith("file://")) {
|
||||||
|
uriString.removePrefix("file://")
|
||||||
|
} else {
|
||||||
|
uriString
|
||||||
|
}
|
||||||
|
java.io.File(path).length()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["ios", "android"],
|
||||||
|
"ios": {
|
||||||
|
"modules": ["ExpoBlueskyVideoCompressModule"]
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"modules": ["expo.modules.blueskyvideocompress.ExpoBlueskyVideoCompressModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {type EventSubscription} from 'expo-modules-core'
|
||||||
|
|
||||||
|
import NativeModule from './src/ExpoBlueskyVideoCompressModule'
|
||||||
|
import {
|
||||||
|
type CodecPreference,
|
||||||
|
type CompressCallbacks,
|
||||||
|
type CompressOptions,
|
||||||
|
type CompressResult,
|
||||||
|
type VideoMetadata,
|
||||||
|
} from './src/types'
|
||||||
|
|
||||||
|
export type {
|
||||||
|
CodecPreference,
|
||||||
|
CompressCallbacks,
|
||||||
|
CompressOptions,
|
||||||
|
CompressResult,
|
||||||
|
VideoMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
class AbortError extends Error {
|
||||||
|
name = 'AbortError'
|
||||||
|
constructor() {
|
||||||
|
super('Aborted')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobIdCounter = 0
|
||||||
|
|
||||||
|
export function probe(uri: string): Promise<VideoMetadata> {
|
||||||
|
return NativeModule.probe(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compress(
|
||||||
|
uri: string,
|
||||||
|
options: CompressOptions = {},
|
||||||
|
callbacks?: CompressCallbacks,
|
||||||
|
): Promise<CompressResult> {
|
||||||
|
const jobId = ++jobIdCounter
|
||||||
|
let subscription: EventSubscription | undefined
|
||||||
|
|
||||||
|
if (callbacks?.signal?.aborted) {
|
||||||
|
return Promise.reject(new AbortError())
|
||||||
|
}
|
||||||
|
|
||||||
|
const nativeOptions = {
|
||||||
|
targetBitrate: options.targetBitrate ?? 0,
|
||||||
|
maxSize: options.maxSize ?? 1920,
|
||||||
|
codec: options.codec ?? 'auto',
|
||||||
|
frameRateCap: options.frameRateCap ?? 30,
|
||||||
|
jobId,
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<CompressResult>((resolve, reject) => {
|
||||||
|
if (callbacks?.onProgress) {
|
||||||
|
subscription = NativeModule.addListener(
|
||||||
|
'onProgress',
|
||||||
|
(event: {id: number; progress: number}) => {
|
||||||
|
if (event.id === jobId) {
|
||||||
|
callbacks.onProgress!(event.progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
NativeModule.cancel(jobId)
|
||||||
|
subscription?.remove()
|
||||||
|
reject(new AbortError())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callbacks?.signal) {
|
||||||
|
callbacks.signal.addEventListener('abort', abortHandler, {once: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeModule.compress(uri, nativeOptions)
|
||||||
|
.then(result => {
|
||||||
|
callbacks?.signal?.removeEventListener('abort', abortHandler)
|
||||||
|
subscription?.remove()
|
||||||
|
resolve(result)
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
callbacks?.signal?.removeEventListener('abort', abortHandler)
|
||||||
|
subscription?.remove()
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import VideoToolbox
|
||||||
|
|
||||||
|
enum CodecCapability {
|
||||||
|
static let isHardwareHEVCEncodeAvailable: Bool = {
|
||||||
|
var encoderListCF: CFArray?
|
||||||
|
let status = VTCopyVideoEncoderList(nil, &encoderListCF)
|
||||||
|
guard status == noErr, let encoderList = encoderListCF as? [[String: Any]] else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return encoderList.contains { encoder in
|
||||||
|
guard let codecTypeValue = encoder[kVTVideoEncoderList_CodecType as String] as? Int,
|
||||||
|
codecTypeValue == Int(kCMVideoCodecType_HEVC) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let isHardware = encoder[kVTVideoEncoderList_IsHardwareAccelerated as String] as? Bool {
|
||||||
|
return isHardware
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
Pod::Spec.new do |s|
|
||||||
|
s.name = 'ExpoBlueskyVideoCompress'
|
||||||
|
s.version = '1.0.0'
|
||||||
|
s.summary = 'Hardware-accelerated video compression for Bluesky'
|
||||||
|
s.description = 'Hardware-accelerated h264/HEVC video compression using AVAssetReader/Writer and VideoToolbox on iOS'
|
||||||
|
s.author = ''
|
||||||
|
s.homepage = 'https://github.com/bluesky-social/social-app'
|
||||||
|
s.platforms = { :ios => '15.1' }
|
||||||
|
s.source = { git: '' }
|
||||||
|
s.static_framework = true
|
||||||
|
|
||||||
|
s.dependency 'ExpoModulesCore'
|
||||||
|
|
||||||
|
s.pod_target_xcconfig = {
|
||||||
|
'DEFINES_MODULE' => 'YES',
|
||||||
|
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
||||||
|
}
|
||||||
|
|
||||||
|
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
||||||
|
end
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import ExpoModulesCore
|
||||||
|
|
||||||
|
public class ExpoBlueskyVideoCompressModule: Module {
|
||||||
|
private var activeCompressors: [Int: VideoCompressor] = [:]
|
||||||
|
private let activeCompressorsLock = NSLock()
|
||||||
|
|
||||||
|
public func definition() -> ModuleDefinition {
|
||||||
|
Name("ExpoBlueskyVideoCompress")
|
||||||
|
|
||||||
|
Events("onProgress")
|
||||||
|
|
||||||
|
AsyncFunction("probe") { (uri: String) -> [String: Any] in
|
||||||
|
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
|
||||||
|
return try await VideoProber.probe(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncFunction("compress") { (uri: String, options: [String: Any]) -> [String: Any] in
|
||||||
|
let url = URL(string: uri) ?? URL(fileURLWithPath: uri)
|
||||||
|
let targetBitrate = options["targetBitrate"] as? Int ?? 0
|
||||||
|
let maxSize = options["maxSize"] as? Int ?? 1920
|
||||||
|
let codecPref = options["codec"] as? String ?? "auto"
|
||||||
|
let frameRateCap = max(1, options["frameRateCap"] as? Int ?? 30)
|
||||||
|
let jobId = options["jobId"] as? Int ?? 0
|
||||||
|
|
||||||
|
let compressor = VideoCompressor(
|
||||||
|
url: url,
|
||||||
|
targetBitrate: targetBitrate,
|
||||||
|
maxSize: maxSize,
|
||||||
|
codecPref: codecPref,
|
||||||
|
frameRateCap: frameRateCap,
|
||||||
|
jobId: jobId,
|
||||||
|
onProgress: { [weak self] id, progress in
|
||||||
|
self?.sendEvent("onProgress", [
|
||||||
|
"id": id,
|
||||||
|
"progress": progress,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.setCompressor(jobId, compressor)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let result = try await compressor.compress()
|
||||||
|
self.setCompressor(jobId, nil)
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
self.setCompressor(jobId, nil)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Function("cancel") { (jobId: Int) in
|
||||||
|
self.cancelCompressor(jobId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setCompressor(_ jobId: Int, _ compressor: VideoCompressor?) {
|
||||||
|
activeCompressorsLock.lock()
|
||||||
|
defer { activeCompressorsLock.unlock() }
|
||||||
|
if let compressor = compressor {
|
||||||
|
activeCompressors[jobId] = compressor
|
||||||
|
} else {
|
||||||
|
activeCompressors.removeValue(forKey: jobId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelCompressor(_ jobId: Int) {
|
||||||
|
activeCompressorsLock.lock()
|
||||||
|
let compressor = activeCompressors.removeValue(forKey: jobId)
|
||||||
|
activeCompressorsLock.unlock()
|
||||||
|
compressor?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import VideoToolbox
|
||||||
|
|
||||||
|
class VideoCompressor {
|
||||||
|
private let url: URL
|
||||||
|
private let targetBitrate: Int
|
||||||
|
private let maxSize: Int
|
||||||
|
private let codecPref: String
|
||||||
|
private let frameRateCap: Int
|
||||||
|
private let jobId: Int
|
||||||
|
private let onProgress: (Int, Double) -> Void
|
||||||
|
private var isCancelled = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
url: URL,
|
||||||
|
targetBitrate: Int,
|
||||||
|
maxSize: Int,
|
||||||
|
codecPref: String,
|
||||||
|
frameRateCap: Int,
|
||||||
|
jobId: Int,
|
||||||
|
onProgress: @escaping (Int, Double) -> Void
|
||||||
|
) {
|
||||||
|
self.url = url
|
||||||
|
self.targetBitrate = targetBitrate
|
||||||
|
self.maxSize = maxSize
|
||||||
|
self.codecPref = codecPref
|
||||||
|
self.frameRateCap = frameRateCap
|
||||||
|
self.jobId = jobId
|
||||||
|
self.onProgress = onProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
isCancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func compress() async throws -> [String: Any] {
|
||||||
|
let asset = AVURLAsset(
|
||||||
|
url: url,
|
||||||
|
options: [AVURLAssetPreferPreciseDurationAndTimingKey: true]
|
||||||
|
)
|
||||||
|
let duration = try await asset.load(.duration)
|
||||||
|
let totalSeconds = CMTimeGetSeconds(duration)
|
||||||
|
guard totalSeconds > 0 else { throw err("Invalid video duration", code: 2) }
|
||||||
|
|
||||||
|
let videoTracks = try await asset.loadTracks(withMediaType: .video)
|
||||||
|
guard let videoTrack = videoTracks.first else {
|
||||||
|
throw err("No video track found", code: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||||
|
let preferredTransform = try await videoTrack.load(.preferredTransform)
|
||||||
|
|
||||||
|
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
|
||||||
|
let displaySize = CGSize(
|
||||||
|
width: abs(rotatedRect.width),
|
||||||
|
height: abs(rotatedRect.height)
|
||||||
|
)
|
||||||
|
let outputSize = scaleEvenly(displaySize: displaySize, maxSize: maxSize)
|
||||||
|
|
||||||
|
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||||
|
|
||||||
|
// 'auto' targets h264 — server pipeline is HLS, which favors h264 (HEVC needs
|
||||||
|
// fMP4 segments + commercial licensing). HEVC remains opt-in via codec: 'hevc'.
|
||||||
|
let useHEVC: Bool
|
||||||
|
switch codecPref {
|
||||||
|
case "hevc": useHEVC = true
|
||||||
|
default: useHEVC = false
|
||||||
|
}
|
||||||
|
let codecType: AVVideoCodecType = useHEVC ? .hevc : .h264
|
||||||
|
let profileLevel: String = useHEVC
|
||||||
|
? kVTProfileLevel_HEVC_Main_AutoLevel as String
|
||||||
|
: kVTProfileLevel_H264_High_AutoLevel as String
|
||||||
|
|
||||||
|
let effectiveBitrate = targetBitrate > 0
|
||||||
|
? targetBitrate
|
||||||
|
: (useHEVC ? 2_500_000 : 3_000_000)
|
||||||
|
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(UUID().uuidString)
|
||||||
|
.appendingPathExtension("mp4")
|
||||||
|
|
||||||
|
let videoComposition = makeRotatingComposition(
|
||||||
|
videoTrack: videoTrack,
|
||||||
|
preferredTransform: preferredTransform,
|
||||||
|
naturalSize: naturalSize,
|
||||||
|
outputSize: outputSize,
|
||||||
|
duration: duration
|
||||||
|
)
|
||||||
|
|
||||||
|
let reader = try AVAssetReader(asset: asset)
|
||||||
|
|
||||||
|
let videoReaderSettings: [String: Any] = [
|
||||||
|
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
|
||||||
|
]
|
||||||
|
let videoReaderOutput = AVAssetReaderVideoCompositionOutput(
|
||||||
|
videoTracks: [videoTrack],
|
||||||
|
videoSettings: videoReaderSettings
|
||||||
|
)
|
||||||
|
videoReaderOutput.videoComposition = videoComposition
|
||||||
|
videoReaderOutput.alwaysCopiesSampleData = false
|
||||||
|
guard reader.canAdd(videoReaderOutput) else {
|
||||||
|
throw err("Cannot read video track", code: 3)
|
||||||
|
}
|
||||||
|
reader.add(videoReaderOutput)
|
||||||
|
|
||||||
|
var compressionProps: [String: Any] = [
|
||||||
|
AVVideoAverageBitRateKey: effectiveBitrate,
|
||||||
|
AVVideoProfileLevelKey: profileLevel,
|
||||||
|
AVVideoMaxKeyFrameIntervalKey: max(frameRateCap * 3, 30),
|
||||||
|
AVVideoExpectedSourceFrameRateKey: frameRateCap,
|
||||||
|
AVVideoAllowFrameReorderingKey: false,
|
||||||
|
kVTCompressionPropertyKey_RealTime as String: true,
|
||||||
|
]
|
||||||
|
let peakBytesPerSecond = Int(Double(effectiveBitrate) / 8.0 * 1.5)
|
||||||
|
compressionProps[kVTCompressionPropertyKey_DataRateLimits as String] = [
|
||||||
|
peakBytesPerSecond, 1.0
|
||||||
|
] as CFArray
|
||||||
|
|
||||||
|
let videoColorProps: [String: Any] = [
|
||||||
|
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
|
||||||
|
AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
|
||||||
|
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
|
||||||
|
]
|
||||||
|
|
||||||
|
let videoWriterSettings: [String: Any] = [
|
||||||
|
AVVideoCodecKey: codecType,
|
||||||
|
AVVideoWidthKey: outputSize.width,
|
||||||
|
AVVideoHeightKey: outputSize.height,
|
||||||
|
AVVideoColorPropertiesKey: videoColorProps,
|
||||||
|
AVVideoCompressionPropertiesKey: compressionProps,
|
||||||
|
]
|
||||||
|
let videoWriterInput = AVAssetWriterInput(
|
||||||
|
mediaType: .video,
|
||||||
|
outputSettings: videoWriterSettings
|
||||||
|
)
|
||||||
|
videoWriterInput.expectsMediaDataInRealTime = false
|
||||||
|
|
||||||
|
let writer = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
|
||||||
|
writer.shouldOptimizeForNetworkUse = true
|
||||||
|
writer.metadata = []
|
||||||
|
guard writer.canAdd(videoWriterInput) else {
|
||||||
|
throw err("Cannot write video track", code: 4)
|
||||||
|
}
|
||||||
|
writer.add(videoWriterInput)
|
||||||
|
|
||||||
|
var audioReaderOutput: AVAssetReaderTrackOutput?
|
||||||
|
var audioWriterInput: AVAssetWriterInput?
|
||||||
|
if let audioTrack = audioTracks.first {
|
||||||
|
let audioDecoderSettings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||||
|
AVLinearPCMBitDepthKey: 16,
|
||||||
|
AVLinearPCMIsFloatKey: false,
|
||||||
|
AVLinearPCMIsBigEndianKey: false,
|
||||||
|
AVLinearPCMIsNonInterleaved: false,
|
||||||
|
]
|
||||||
|
let audioOutput = AVAssetReaderTrackOutput(
|
||||||
|
track: audioTrack,
|
||||||
|
outputSettings: audioDecoderSettings
|
||||||
|
)
|
||||||
|
audioOutput.alwaysCopiesSampleData = false
|
||||||
|
if reader.canAdd(audioOutput) {
|
||||||
|
reader.add(audioOutput)
|
||||||
|
audioReaderOutput = audioOutput
|
||||||
|
|
||||||
|
let audioEncoderSettings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 44100,
|
||||||
|
AVNumberOfChannelsKey: 2,
|
||||||
|
AVEncoderBitRateKey: 128_000,
|
||||||
|
]
|
||||||
|
let audioInput = AVAssetWriterInput(
|
||||||
|
mediaType: .audio,
|
||||||
|
outputSettings: audioEncoderSettings
|
||||||
|
)
|
||||||
|
audioInput.expectsMediaDataInRealTime = false
|
||||||
|
if writer.canAdd(audioInput) {
|
||||||
|
writer.add(audioInput)
|
||||||
|
audioWriterInput = audioInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard reader.startReading() else {
|
||||||
|
throw reader.error ?? err("Reader failed to start", code: 8)
|
||||||
|
}
|
||||||
|
guard writer.startWriting() else {
|
||||||
|
throw writer.error ?? err("Writer failed to start", code: 9)
|
||||||
|
}
|
||||||
|
writer.startSession(atSourceTime: .zero)
|
||||||
|
|
||||||
|
let minFrameIntervalSeconds = 1.0 / Double(frameRateCap)
|
||||||
|
let minFrameInterval = CMTime(
|
||||||
|
seconds: minFrameIntervalSeconds,
|
||||||
|
preferredTimescale: 600
|
||||||
|
)
|
||||||
|
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
group.addTask { [self] in
|
||||||
|
await processVideoTrack(
|
||||||
|
readerOutput: videoReaderOutput,
|
||||||
|
writerInput: videoWriterInput,
|
||||||
|
totalDuration: totalSeconds,
|
||||||
|
minFrameInterval: minFrameInterval
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let audioOutput = audioReaderOutput, let audioInput = audioWriterInput {
|
||||||
|
group.addTask { [self] in
|
||||||
|
await processAudioTrack(
|
||||||
|
readerOutput: audioOutput,
|
||||||
|
writerInput: audioInput
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await group.waitForAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCancelled {
|
||||||
|
writer.cancelWriting()
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
throw err("Compression cancelled", code: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reader.status == .failed {
|
||||||
|
let error = reader.error ?? err("Reader failed", code: 6)
|
||||||
|
writer.cancelWriting()
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
await writer.finishWriting()
|
||||||
|
|
||||||
|
if writer.status == .failed {
|
||||||
|
let error = writer.error ?? err("Writer failed", code: 7)
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress(jobId, 1.0)
|
||||||
|
|
||||||
|
let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path)
|
||||||
|
let fileSize = attributes[.size] as? Int ?? 0
|
||||||
|
|
||||||
|
return [
|
||||||
|
"uri": outputURL.absoluteString,
|
||||||
|
"size": fileSize,
|
||||||
|
"mimeType": "video/mp4",
|
||||||
|
"width": outputSize.width,
|
||||||
|
"height": outputSize.height,
|
||||||
|
"duration": totalSeconds,
|
||||||
|
"codec": useHEVC ? "hevc" : "h264",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processVideoTrack(
|
||||||
|
readerOutput: AVAssetReaderOutput,
|
||||||
|
writerInput: AVAssetWriterInput,
|
||||||
|
totalDuration: Double,
|
||||||
|
minFrameInterval: CMTime
|
||||||
|
) async {
|
||||||
|
var lastProgressTime: CFAbsoluteTime = 0
|
||||||
|
var lastAppendedPTS: CMTime?
|
||||||
|
var finished = false
|
||||||
|
|
||||||
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||||
|
writerInput.requestMediaDataWhenReady(
|
||||||
|
on: DispatchQueue(label: "com.bsky.videocompress.video")
|
||||||
|
) {
|
||||||
|
let finish = {
|
||||||
|
if !finished {
|
||||||
|
finished = true
|
||||||
|
writerInput.markAsFinished()
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while writerInput.isReadyForMoreMediaData {
|
||||||
|
if finished { return }
|
||||||
|
if self.isCancelled {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||||
|
if let last = lastAppendedPTS {
|
||||||
|
let delta = CMTimeSubtract(pts, last)
|
||||||
|
if CMTimeCompare(delta, minFrameInterval) < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastAppendedPTS = pts
|
||||||
|
|
||||||
|
if !writerInput.append(sampleBuffer) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = CFAbsoluteTimeGetCurrent()
|
||||||
|
if now - lastProgressTime >= 0.1 {
|
||||||
|
lastProgressTime = now
|
||||||
|
let progress = min(CMTimeGetSeconds(pts) / totalDuration, 1.0)
|
||||||
|
self.onProgress(self.jobId, progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processAudioTrack(
|
||||||
|
readerOutput: AVAssetReaderOutput,
|
||||||
|
writerInput: AVAssetWriterInput
|
||||||
|
) async {
|
||||||
|
var finished = false
|
||||||
|
|
||||||
|
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||||
|
writerInput.requestMediaDataWhenReady(
|
||||||
|
on: DispatchQueue(label: "com.bsky.videocompress.audio")
|
||||||
|
) {
|
||||||
|
let finish = {
|
||||||
|
if !finished {
|
||||||
|
finished = true
|
||||||
|
writerInput.markAsFinished()
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while writerInput.isReadyForMoreMediaData {
|
||||||
|
if finished { return }
|
||||||
|
if self.isCancelled {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let sampleBuffer = readerOutput.copyNextSampleBuffer() else {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !writerInput.append(sampleBuffer) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRotatingComposition(
|
||||||
|
videoTrack: AVAssetTrack,
|
||||||
|
preferredTransform: CGAffineTransform,
|
||||||
|
naturalSize: CGSize,
|
||||||
|
outputSize: (width: Int, height: Int),
|
||||||
|
duration: CMTime
|
||||||
|
) -> AVMutableVideoComposition {
|
||||||
|
let composition = AVMutableVideoComposition()
|
||||||
|
composition.renderSize = CGSize(width: outputSize.width, height: outputSize.height)
|
||||||
|
composition.frameDuration = CMTime(value: 1, timescale: Int32(frameRateCap))
|
||||||
|
|
||||||
|
let rotatedRect = CGRect(origin: .zero, size: naturalSize).applying(preferredTransform)
|
||||||
|
let translate = CGAffineTransform(
|
||||||
|
translationX: -rotatedRect.minX,
|
||||||
|
y: -rotatedRect.minY
|
||||||
|
)
|
||||||
|
let displaySize = CGSize(
|
||||||
|
width: abs(rotatedRect.width),
|
||||||
|
height: abs(rotatedRect.height)
|
||||||
|
)
|
||||||
|
let scaleX = CGFloat(outputSize.width) / displaySize.width
|
||||||
|
let scaleY = CGFloat(outputSize.height) / displaySize.height
|
||||||
|
let scale = CGAffineTransform(scaleX: scaleX, y: scaleY)
|
||||||
|
let combined = preferredTransform.concatenating(translate).concatenating(scale)
|
||||||
|
|
||||||
|
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: videoTrack)
|
||||||
|
layerInstruction.setTransform(combined, at: .zero)
|
||||||
|
|
||||||
|
let instruction = AVMutableVideoCompositionInstruction()
|
||||||
|
instruction.timeRange = CMTimeRange(start: .zero, duration: duration)
|
||||||
|
instruction.layerInstructions = [layerInstruction]
|
||||||
|
|
||||||
|
composition.instructions = [instruction]
|
||||||
|
return composition
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scaleEvenly(displaySize: CGSize, maxSize: Int) -> (width: Int, height: Int) {
|
||||||
|
let cap = CGFloat(maxSize)
|
||||||
|
let scale: CGFloat
|
||||||
|
if displaySize.width <= cap && displaySize.height <= cap {
|
||||||
|
scale = 1.0
|
||||||
|
} else if displaySize.width > displaySize.height {
|
||||||
|
scale = cap / displaySize.width
|
||||||
|
} else {
|
||||||
|
scale = cap / displaySize.height
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
roundToEven(Int(displaySize.width * scale)),
|
||||||
|
roundToEven(Int(displaySize.height * scale))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func roundToEven(_ value: Int) -> Int {
|
||||||
|
return value % 2 == 0 ? value : value - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func err(_ message: String, code: Int) -> NSError {
|
||||||
|
return NSError(
|
||||||
|
domain: "ExpoBlueskyVideoCompress",
|
||||||
|
code: code,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: message]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct VideoProber {
|
||||||
|
static func probe(url: URL) async throws -> [String: Any] {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
|
||||||
|
let duration = try await asset.load(.duration)
|
||||||
|
let tracks = try await asset.loadTracks(withMediaType: .video)
|
||||||
|
|
||||||
|
guard let videoTrack = tracks.first else {
|
||||||
|
throw NSError(
|
||||||
|
domain: "ExpoBlueskyVideoCompress",
|
||||||
|
code: 1,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "No video track found"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let naturalSize = try await videoTrack.load(.naturalSize)
|
||||||
|
let preferredTransform = try await videoTrack.load(.preferredTransform)
|
||||||
|
let estimatedDataRate = try await videoTrack.load(.estimatedDataRate)
|
||||||
|
let nominalFrameRate = try await videoTrack.load(.nominalFrameRate)
|
||||||
|
let formatDescriptions = try await videoTrack.load(.formatDescriptions)
|
||||||
|
|
||||||
|
var codec = "unknown"
|
||||||
|
var isHDR = false
|
||||||
|
if let formatDescription = formatDescriptions.first {
|
||||||
|
let subType = CMFormatDescriptionGetMediaSubType(formatDescription)
|
||||||
|
codec = fourCCToString(subType)
|
||||||
|
// Dolby Vision codecs are HDR by definition.
|
||||||
|
let dolbyVisionSubtypes: Set<String> = ["dvhe", "dvh1", "dvav", "dva1"]
|
||||||
|
if dolbyVisionSubtypes.contains(codec) {
|
||||||
|
isHDR = true
|
||||||
|
} else if let extensions = CMFormatDescriptionGetExtensions(formatDescription)
|
||||||
|
as? [String: Any]
|
||||||
|
{
|
||||||
|
let transferKey = kCMFormatDescriptionExtension_TransferFunction as String
|
||||||
|
if let transfer = extensions[transferKey] as? String {
|
||||||
|
let hlg = kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG as String
|
||||||
|
let pq = kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ as String
|
||||||
|
isHDR = transfer == hlg || transfer == pq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let rotation = rotationFromTransform(preferredTransform)
|
||||||
|
let isRotated = rotation == 90 || rotation == 270
|
||||||
|
let width = isRotated ? Int(naturalSize.height) : Int(naturalSize.width)
|
||||||
|
let height = isRotated ? Int(naturalSize.width) : Int(naturalSize.height)
|
||||||
|
|
||||||
|
let audioTracks = try await asset.loadTracks(withMediaType: .audio)
|
||||||
|
let hasAudio = !audioTracks.isEmpty
|
||||||
|
|
||||||
|
let fileSize: Int
|
||||||
|
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
|
let size = attributes[.size] as? Int {
|
||||||
|
fileSize = size
|
||||||
|
} else {
|
||||||
|
fileSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let mimeType: String
|
||||||
|
if let utType = UTType(filenameExtension: url.pathExtension) {
|
||||||
|
mimeType = utType.preferredMIMEType ?? "video/mp4"
|
||||||
|
} else {
|
||||||
|
mimeType = "video/mp4"
|
||||||
|
}
|
||||||
|
|
||||||
|
let durationSeconds = CMTimeGetSeconds(duration)
|
||||||
|
var bitrate = Int(estimatedDataRate)
|
||||||
|
if bitrate == 0 && durationSeconds > 0 {
|
||||||
|
bitrate = Int(Double(fileSize * 8) / durationSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"duration": durationSeconds,
|
||||||
|
"bitrate": bitrate,
|
||||||
|
"fileSize": fileSize,
|
||||||
|
"mimeType": mimeType,
|
||||||
|
"codec": codec,
|
||||||
|
"hasAudio": hasAudio,
|
||||||
|
"frameRate": nominalFrameRate,
|
||||||
|
"rotation": rotation,
|
||||||
|
"isHDR": isHDR
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rotationFromTransform(_ transform: CGAffineTransform) -> Int {
|
||||||
|
let angle = atan2(transform.b, transform.a)
|
||||||
|
let degrees = Int(round(angle * 180.0 / .pi))
|
||||||
|
return ((degrees % 360) + 360) % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fourCCToString(_ code: FourCharCode) -> String {
|
||||||
|
let chars: [Character] = [
|
||||||
|
Character(UnicodeScalar((code >> 24) & 0xFF)!),
|
||||||
|
Character(UnicodeScalar((code >> 16) & 0xFF)!),
|
||||||
|
Character(UnicodeScalar((code >> 8) & 0xFF)!),
|
||||||
|
Character(UnicodeScalar(code & 0xFF)!)
|
||||||
|
]
|
||||||
|
return String(chars).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {type EventSubscription, requireNativeModule} from 'expo-modules-core'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CompressResult,
|
||||||
|
type NativeCompressOptions,
|
||||||
|
type VideoMetadata,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
type ProgressEvent = {id: number; progress: number}
|
||||||
|
|
||||||
|
interface ExpoBlueskyVideoCompressModule {
|
||||||
|
probe(uri: string): Promise<VideoMetadata>
|
||||||
|
compress(uri: string, options: NativeCompressOptions): Promise<CompressResult>
|
||||||
|
cancel(jobId: number): void
|
||||||
|
addListener(
|
||||||
|
eventName: 'onProgress',
|
||||||
|
listener: (event: ProgressEvent) => void,
|
||||||
|
): EventSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
export default requireNativeModule<ExpoBlueskyVideoCompressModule>(
|
||||||
|
'ExpoBlueskyVideoCompress',
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export type CodecPreference = 'auto' | 'hevc' | 'h264'
|
||||||
|
|
||||||
|
export type VideoMetadata = {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
duration: number
|
||||||
|
bitrate: number
|
||||||
|
fileSize: number
|
||||||
|
mimeType: string
|
||||||
|
codec: string
|
||||||
|
hasAudio: boolean
|
||||||
|
frameRate: number
|
||||||
|
rotation: number
|
||||||
|
isHDR: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompressOptions = {
|
||||||
|
targetBitrate?: number
|
||||||
|
maxSize?: number
|
||||||
|
codec?: CodecPreference
|
||||||
|
frameRateCap?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompressCallbacks = {
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompressResult = {
|
||||||
|
uri: string
|
||||||
|
size: number
|
||||||
|
mimeType: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
duration: number
|
||||||
|
codec: 'h264' | 'hevc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NativeCompressOptions = {
|
||||||
|
targetBitrate: number
|
||||||
|
maxSize: number
|
||||||
|
codec: CodecPreference
|
||||||
|
frameRateCap: number
|
||||||
|
jobId: number
|
||||||
|
}
|
||||||
+1
-3
@@ -90,8 +90,7 @@
|
|||||||
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
|
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
|
||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
|
||||||
"icons:optimize": "svgo -f ./assets/icons",
|
"icons:optimize": "svgo -f ./assets/icons",
|
||||||
"prettier": "prettier --check .",
|
"prettier": "prettier --check ."
|
||||||
"update-actions": "pnpm dlx actions-up --min-age 7"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.20.23",
|
"@atproto/api": "0.20.23",
|
||||||
@@ -297,7 +296,6 @@
|
|||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"lint-staged": "^13.2.3",
|
"lint-staged": "^13.2.3",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"react-native-dotenv": "^3.4.11",
|
|
||||||
"react-refresh": "^0.14.0",
|
"react-refresh": "^0.14.0",
|
||||||
"svgo": "^3.3.2",
|
"svgo": "^3.3.2",
|
||||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||||
|
|||||||
Generated
-13
@@ -848,9 +848,6 @@ importers:
|
|||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.8.3
|
specifier: ^3.8.3
|
||||||
version: 3.8.3
|
version: 3.8.3
|
||||||
react-native-dotenv:
|
|
||||||
specifier: ^3.4.11
|
|
||||||
version: 3.4.11(@babel/runtime@7.29.2)
|
|
||||||
react-refresh:
|
react-refresh:
|
||||||
specifier: ^0.14.0
|
specifier: ^0.14.0
|
||||||
version: 0.14.2
|
version: 0.14.2
|
||||||
@@ -8008,11 +8005,6 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
react-native-dotenv@3.4.11:
|
|
||||||
resolution: {integrity: sha512-6vnIE+WHABSeHCaYP6l3O1BOEhWxKH6nHAdV7n/wKn/sciZ64zPPp2NUdEUf1m7g4uuzlLbjgr+6uDt89q2DOg==}
|
|
||||||
peerDependencies:
|
|
||||||
'@babel/runtime': ^7.20.6
|
|
||||||
|
|
||||||
react-native-drawer-layout@4.2.3:
|
react-native-drawer-layout@4.2.3:
|
||||||
resolution: {integrity: sha512-mj/riptO4O8D9loG9N76M4VqLR9EQXLQkx/nN4GVgT/y46wpVlrqqLlDAqFVylTDEW/u3WvZhZph+8Iu64MsZw==}
|
resolution: {integrity: sha512-mj/riptO4O8D9loG9N76M4VqLR9EQXLQkx/nN4GVgT/y46wpVlrqqLlDAqFVylTDEW/u3WvZhZph+8Iu64MsZw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -17851,11 +17843,6 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||||
|
|
||||||
react-native-dotenv@3.4.11(@babel/runtime@7.29.2):
|
|
||||||
dependencies:
|
|
||||||
'@babel/runtime': 7.29.2
|
|
||||||
dotenv: 16.6.1
|
|
||||||
|
|
||||||
react-native-drawer-layout@4.2.3(patch_hash=74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130)(react-native-gesture-handler@2.28.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
react-native-drawer-layout@4.2.3(patch_hash=74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130)(react-native-gesture-handler@2.28.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
color: 4.2.3
|
color: 4.2.3
|
||||||
|
|||||||
@@ -1365,6 +1365,25 @@ export type Events = {
|
|||||||
engine: string
|
engine: string
|
||||||
sourceBytes?: number
|
sourceBytes?: number
|
||||||
}
|
}
|
||||||
|
// Native-only. Raw container metadata returned by the new module's probe()
|
||||||
|
// (bitrate, codec, HDR, frame rate, rotation, etc.). Fires once per upload
|
||||||
|
// between compressStarted and the compressSkipped/compressCompleted decision.
|
||||||
|
// The web (mediabunny) and legacy rn-compressor engines do not surface this.
|
||||||
|
'video:upload:probed': {
|
||||||
|
uploadId: string
|
||||||
|
engine: string
|
||||||
|
mimeType: string
|
||||||
|
codec: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
duration: number
|
||||||
|
bitrate: number
|
||||||
|
fileSize: number
|
||||||
|
hasAudio: boolean
|
||||||
|
frameRate: number
|
||||||
|
rotation: number
|
||||||
|
isHDR: boolean
|
||||||
|
}
|
||||||
'video:upload:compressCompleted': {
|
'video:upload:compressCompleted': {
|
||||||
uploadId: string
|
uploadId: string
|
||||||
engine: string
|
engine: string
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback} from 'react'
|
||||||
import {View} from 'react-native'
|
|
||||||
import {Sift, type UseSiftReturn} from '@bsky.app/sift'
|
import {Sift, type UseSiftReturn} from '@bsky.app/sift'
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
@@ -22,7 +21,7 @@ function renderItem(
|
|||||||
case 'search':
|
case 'search':
|
||||||
return <AutocompleteItemSearch {...item} />
|
return <AutocompleteItemSearch {...item} />
|
||||||
default:
|
default:
|
||||||
return <View />
|
return <></>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +32,7 @@ export function Autocomplete({
|
|||||||
render = renderItem,
|
render = renderItem,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
fullWidth = false,
|
||||||
}: {
|
}: {
|
||||||
inverted?: boolean
|
inverted?: boolean
|
||||||
sift: UseSiftReturn
|
sift: UseSiftReturn
|
||||||
@@ -40,6 +40,12 @@ export function Autocomplete({
|
|||||||
render?: Parameters<typeof Sift<AutocompleteItem>>[0]['render']
|
render?: Parameters<typeof Sift<AutocompleteItem>>[0]['render']
|
||||||
onSelect: (item: AutocompleteItem) => void
|
onSelect: (item: AutocompleteItem) => void
|
||||||
onDismiss: () => void
|
onDismiss: () => void
|
||||||
|
/**
|
||||||
|
* Match the anchor's width instead of the default capped width. Use for
|
||||||
|
* full-width anchors like the search bar; leave off for inline mention
|
||||||
|
* inputs.
|
||||||
|
*/
|
||||||
|
fullWidth?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
@@ -50,6 +56,8 @@ export function Autocomplete({
|
|||||||
useOnKeyboard('keyboardDidShow', updatePosition)
|
useOnKeyboard('keyboardDidShow', updatePosition)
|
||||||
useOnKeyboard('keyboardDidHide', updatePosition)
|
useOnKeyboard('keyboardDidHide', updatePosition)
|
||||||
|
|
||||||
|
const maxWidth = IS_WEB && !fullWidth ? {maxWidth: 300} : {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<Sift
|
<Sift
|
||||||
@@ -58,16 +66,7 @@ export function Autocomplete({
|
|||||||
data={data}
|
data={data}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
outerStyle={[
|
outerStyle={[a.rounded_md, a.w_full, t.atoms.shadow_lg, maxWidth]}
|
||||||
a.rounded_md,
|
|
||||||
a.w_full,
|
|
||||||
t.atoms.shadow_lg,
|
|
||||||
IS_WEB
|
|
||||||
? {
|
|
||||||
maxWidth: 300,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
]}
|
|
||||||
innerStyle={[
|
innerStyle={[
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
@@ -75,11 +74,7 @@ export function Autocomplete({
|
|||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
IS_WEB
|
maxWidth,
|
||||||
? {
|
|
||||||
maxWidth: 300,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
]}
|
]}
|
||||||
render={render}
|
render={render}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -45,4 +45,5 @@ export type AutocompleteItemProps = Parameters<
|
|||||||
export type AutocompleteApi = {
|
export type AutocompleteApi = {
|
||||||
query: string
|
query: string
|
||||||
items: AutocompleteItem[]
|
items: AutocompleteItem[]
|
||||||
|
isFetching: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export function useAutocomplete({
|
|||||||
return {
|
return {
|
||||||
query: q,
|
query: q,
|
||||||
items,
|
items,
|
||||||
|
isFetching: query.isFetching,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,16 +74,17 @@ const BORDER_RADIUS = 20
|
|||||||
const SQUARED_BORDER_RADIUS = 4
|
const SQUARED_BORDER_RADIUS = 4
|
||||||
const DISPLAY_NAME_INSET = 20
|
const DISPLAY_NAME_INSET = 20
|
||||||
|
|
||||||
function messageIsReply(
|
export type MessageItemNeighbor =
|
||||||
message:
|
| ChatBskyConvoDefs.MessageView
|
||||||
| ChatBskyConvoDefs.MessageView
|
| ChatBskyConvoDefs.DeletedMessageView
|
||||||
| ChatBskyConvoDefs.DeletedMessageView
|
| null
|
||||||
| null,
|
|
||||||
): boolean {
|
function messageIsReply(message: MessageItemNeighbor): boolean {
|
||||||
return (
|
return (
|
||||||
ChatBskyConvoDefs.isMessageView(message) &&
|
ChatBskyConvoDefs.isMessageView(message) &&
|
||||||
(ChatBskyConvoDefs.isMessageView(message.replyTo) ||
|
(ChatBskyConvoDefs.isMessageView(message.replyTo) ||
|
||||||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo))
|
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) ||
|
||||||
|
ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(message.replyTo))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +97,7 @@ function isWithinClusterBoundary({
|
|||||||
}: {
|
}: {
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
message: ChatBskyConvoDefs.MessageView
|
message: ChatBskyConvoDefs.MessageView
|
||||||
adjacentMessage:
|
adjacentMessage: MessageItemNeighbor
|
||||||
| ChatBskyConvoDefs.MessageView
|
|
||||||
| ChatBskyConvoDefs.DeletedMessageView
|
|
||||||
| null
|
|
||||||
isFromSameSender: boolean
|
isFromSameSender: boolean
|
||||||
direction: 'prev' | 'next'
|
direction: 'prev' | 'next'
|
||||||
}): boolean {
|
}): boolean {
|
||||||
@@ -135,14 +133,8 @@ let MessageItem = ({
|
|||||||
}: {
|
}: {
|
||||||
item: ConvoItem & {type: 'message' | 'pending-message'}
|
item: ConvoItem & {type: 'message' | 'pending-message'}
|
||||||
isGroupChat?: boolean
|
isGroupChat?: boolean
|
||||||
prevMessage:
|
prevMessage: MessageItemNeighbor
|
||||||
| ChatBskyConvoDefs.MessageView
|
nextMessage: MessageItemNeighbor
|
||||||
| ChatBskyConvoDefs.DeletedMessageView
|
|
||||||
| null
|
|
||||||
nextMessage:
|
|
||||||
| ChatBskyConvoDefs.MessageView
|
|
||||||
| ChatBskyConvoDefs.DeletedMessageView
|
|
||||||
| null
|
|
||||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
@@ -157,14 +149,22 @@ let MessageItem = ({
|
|||||||
const {openReactions} = useMessageDialogs()
|
const {openReactions} = useMessageDialogs()
|
||||||
const {scrollToMessage, highlightedMessage} = useMessageReplies()
|
const {scrollToMessage, highlightedMessage} = useMessageReplies()
|
||||||
|
|
||||||
// `replyTo` comes back hydrated as the referenced message (or a deleted-
|
// `replyTo` comes back hydrated as the referenced message, a deleted-message
|
||||||
// message tombstone). Narrow away the open-union fallback so we only render
|
// tombstone, or a before-joined placeholder. Narrow away the open-union
|
||||||
// shapes we understand.
|
// fallback so we only render shapes we understand.
|
||||||
const replyTo =
|
const replyTo =
|
||||||
ChatBskyConvoDefs.isMessageView(message.replyTo) ||
|
ChatBskyConvoDefs.isMessageView(message.replyTo) ||
|
||||||
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo)
|
ChatBskyConvoDefs.isDeletedMessageView(message.replyTo) ||
|
||||||
|
ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(message.replyTo)
|
||||||
? message.replyTo
|
? message.replyTo
|
||||||
: undefined
|
: undefined
|
||||||
|
const replyToMessageId =
|
||||||
|
replyTo && !ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(replyTo)
|
||||||
|
? replyTo.id
|
||||||
|
: undefined
|
||||||
|
const onPressReplyTo = replyToMessageId
|
||||||
|
? () => scrollToMessage(replyToMessageId)
|
||||||
|
: undefined
|
||||||
|
|
||||||
const isPending = item.type === 'pending-message'
|
const isPending = item.type === 'pending-message'
|
||||||
|
|
||||||
@@ -468,7 +468,7 @@ let MessageItem = ({
|
|||||||
isGroupChat={isGroupChat}
|
isGroupChat={isGroupChat}
|
||||||
replierDisplayName={displayName}
|
replierDisplayName={displayName}
|
||||||
relatedProfiles={relatedProfiles}
|
relatedProfiles={relatedProfiles}
|
||||||
onPress={() => scrollToMessage(replyTo.id)}
|
onPress={onPressReplyTo}
|
||||||
/>
|
/>
|
||||||
) : displayName && showDisplayName ? (
|
) : displayName && showDisplayName ? (
|
||||||
<Text
|
<Text
|
||||||
@@ -541,7 +541,7 @@ let MessageItem = ({
|
|||||||
replyTo={replyTo}
|
replyTo={replyTo}
|
||||||
isFromSelf={isFromSelf}
|
isFromSelf={isFromSelf}
|
||||||
relatedProfiles={relatedProfiles}
|
relatedProfiles={relatedProfiles}
|
||||||
onPress={() => scrollToMessage(replyTo.id)}
|
onPress={onPressReplyTo}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<RichText
|
<RichText
|
||||||
@@ -751,28 +751,56 @@ function ReplyCaption({
|
|||||||
relatedProfiles,
|
relatedProfiles,
|
||||||
onPress,
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
|
replyTo:
|
||||||
|
| ChatBskyConvoDefs.MessageView
|
||||||
|
| ChatBskyConvoDefs.DeletedMessageView
|
||||||
|
| ChatBskyConvoDefs.MessageBeforeUserJoinedGroupView
|
||||||
isFromSelf: boolean
|
isFromSelf: boolean
|
||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
replierDisplayName: string | null
|
replierDisplayName: string | null
|
||||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
onPress: () => void
|
onPress?: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did
|
let caption: string = ''
|
||||||
const originalProfile = relatedProfiles.get(replyTo.sender.did)
|
if (
|
||||||
const originalName = originalSenderIsSelf
|
ChatBskyConvoDefs.isMessageView(replyTo) ||
|
||||||
? null
|
ChatBskyConvoDefs.isDeletedMessageView(replyTo)
|
||||||
: originalProfile
|
) {
|
||||||
? createSanitizedDisplayName(originalProfile)
|
const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did
|
||||||
: null
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
label={l`Scroll to the message this is replying to`}
|
label={
|
||||||
|
onPress
|
||||||
|
? l`Scroll to the message this is replying to`
|
||||||
|
: l`A reply to a message sent before you joined`
|
||||||
|
}
|
||||||
|
disabled={!onPress}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
@@ -796,23 +824,7 @@ function ReplyCaption({
|
|||||||
style={[a.text_xs, a.flex_shrink, t.atoms.text_contrast_medium]}
|
style={[a.text_xs, a.flex_shrink, t.atoms.text_contrast_medium]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
emoji>
|
emoji>
|
||||||
{isFromSelf ? (
|
{caption}
|
||||||
originalSenderIsSelf ? (
|
|
||||||
<Trans>You replied to yourself</Trans>
|
|
||||||
) : originalName ? (
|
|
||||||
<Trans>You replied to {originalName}</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>You replied</Trans>
|
|
||||||
)
|
|
||||||
) : originalSenderIsSelf ? (
|
|
||||||
<Trans>{replierDisplayName} replied to you</Trans>
|
|
||||||
) : originalName ? (
|
|
||||||
<Trans>
|
|
||||||
{replierDisplayName} replied to {originalName}
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>{replierDisplayName} replied</Trans>
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@@ -828,17 +840,25 @@ function ReplyQuote({
|
|||||||
relatedProfiles,
|
relatedProfiles,
|
||||||
onPress,
|
onPress,
|
||||||
}: {
|
}: {
|
||||||
replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView
|
replyTo:
|
||||||
|
| ChatBskyConvoDefs.MessageView
|
||||||
|
| ChatBskyConvoDefs.DeletedMessageView
|
||||||
|
| ChatBskyConvoDefs.MessageBeforeUserJoinedGroupView
|
||||||
isFromSelf: boolean
|
isFromSelf: boolean
|
||||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||||
onPress: () => void
|
onPress?: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const getReplyPreviewText = useReplyPreviewText()
|
const getReplyPreviewText = useReplyPreviewText()
|
||||||
|
|
||||||
|
const senderDid =
|
||||||
|
ChatBskyConvoDefs.isMessageView(replyTo) ||
|
||||||
|
ChatBskyConvoDefs.isDeletedMessageView(replyTo)
|
||||||
|
? replyTo.sender.did
|
||||||
|
: undefined
|
||||||
const senderProfile = useMaybeProfileShadow(
|
const senderProfile = useMaybeProfileShadow(
|
||||||
relatedProfiles.get(replyTo.sender.did),
|
senderDid ? relatedProfiles.get(senderDid) : undefined,
|
||||||
)
|
)
|
||||||
// Hide the quoted content if we block, or are blocked by, the original
|
// Hide the quoted content if we block, or are blocked by, the original
|
||||||
// sender - mirroring how the message bubble itself is hidden.
|
// sender - mirroring how the message bubble itself is hidden.
|
||||||
@@ -866,6 +886,12 @@ function ReplyQuote({
|
|||||||
subtle = true
|
subtle = true
|
||||||
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
|
} else if (ChatBskyConvoDefs.isMessageView(replyTo)) {
|
||||||
;({text, subtle} = getReplyPreviewText(replyTo))
|
;({text, subtle} = getReplyPreviewText(replyTo))
|
||||||
|
} else if (ChatBskyConvoDefs.isMessageBeforeUserJoinedGroupView(replyTo)) {
|
||||||
|
text = l({
|
||||||
|
message: `(message sent before you joined)`,
|
||||||
|
comment: 'A reply summary in chat',
|
||||||
|
})
|
||||||
|
subtle = true
|
||||||
} else {
|
} else {
|
||||||
text = l({message: '(deleted message)', comment: 'A reply summary in chat'})
|
text = l({message: '(deleted message)', comment: 'A reply summary in chat'})
|
||||||
subtle = true
|
subtle = true
|
||||||
@@ -874,10 +900,13 @@ function ReplyQuote({
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
label={
|
label={
|
||||||
senderName
|
!onPress
|
||||||
? l`Replied-to message from ${senderName}, tap to scroll to it`
|
? l`Replied-to message was sent before you joined`
|
||||||
: l`Replied-to message, tap to scroll to it`
|
: senderName
|
||||||
|
? l`Replied-to message from ${senderName}, tap to scroll to it`
|
||||||
|
: l`Replied-to message, tap to scroll to it`
|
||||||
}
|
}
|
||||||
|
disabled={!onPress}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[
|
style={[
|
||||||
a.mb_xs,
|
a.mb_xs,
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import {Children, cloneElement, Fragment, isValidElement} from 'react'
|
|
||||||
import {View} from 'react-native'
|
|
||||||
|
|
||||||
import {atoms, useTheme} from '#/alf'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NOT FINISHED, just here as a reference
|
|
||||||
*/
|
|
||||||
export function InputGroup(props: React.PropsWithChildren<{}>) {
|
|
||||||
const t = useTheme()
|
|
||||||
const children = Children.toArray(props.children)
|
|
||||||
const total = children.length
|
|
||||||
return (
|
|
||||||
<View style={[atoms.w_full]}>
|
|
||||||
{children.map((child, i) => {
|
|
||||||
return isValidElement(child) ? (
|
|
||||||
<Fragment key={i}>
|
|
||||||
{i > 0 ? (
|
|
||||||
<View
|
|
||||||
style={[atoms.border_b, {borderColor: t.palette.contrast_500}]}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{cloneElement(child, {
|
|
||||||
// @ts-ignore
|
|
||||||
style: [
|
|
||||||
// @ts-ignore
|
|
||||||
...(Array.isArray(child.props?.style)
|
|
||||||
? // @ts-ignore
|
|
||||||
child.props.style
|
|
||||||
: // @ts-ignore
|
|
||||||
[child.props.style || {}]),
|
|
||||||
{
|
|
||||||
borderTopLeftRadius: i > 0 ? 0 : undefined,
|
|
||||||
borderTopRightRadius: i > 0 ? 0 : undefined,
|
|
||||||
borderBottomLeftRadius: i < total - 1 ? 0 : undefined,
|
|
||||||
borderBottomRightRadius: i < total - 1 ? 0 : undefined,
|
|
||||||
borderBottomWidth: i < total - 1 ? 0 : undefined,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
) : null
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
import {View} from 'react-native'
|
|
||||||
import {msg} from '@lingui/core/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
|
||||||
import {atoms as a, web} from '#/alf'
|
|
||||||
import {Admonition} from '#/components/Admonition'
|
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
|
||||||
import * as Dialog from '#/components/Dialog'
|
|
||||||
import {Loader} from '#/components/Loader'
|
|
||||||
import * as Toast from '#/components/Toast'
|
|
||||||
import {Span, Text} from '#/components/Typography'
|
|
||||||
import {IS_NATIVE} from '#/env'
|
|
||||||
import {useUpdateLiveEventPreferences} from '#/features/liveEvents/preferences'
|
|
||||||
import {
|
|
||||||
type LiveEventFeed,
|
|
||||||
type LiveEventFeedMetricContext,
|
|
||||||
} from '#/features/liveEvents/types'
|
|
||||||
|
|
||||||
export {useDialogControl} from '#/components/Dialog'
|
|
||||||
|
|
||||||
export function LiveEventFeedOptionsMenu({
|
|
||||||
control,
|
|
||||||
feed,
|
|
||||||
metricContext,
|
|
||||||
}: {
|
|
||||||
control: Dialog.DialogControlProps
|
|
||||||
feed: LiveEventFeed
|
|
||||||
metricContext: LiveEventFeedMetricContext
|
|
||||||
}) {
|
|
||||||
const {_} = useLingui()
|
|
||||||
return (
|
|
||||||
<Dialog.Outer control={control}>
|
|
||||||
<Dialog.Handle />
|
|
||||||
<Dialog.ScrollableInner
|
|
||||||
label={_(msg`Configure live event banner`)}
|
|
||||||
style={[web({maxWidth: 400})]}>
|
|
||||||
<Inner control={control} feed={feed} metricContext={metricContext} />
|
|
||||||
<Dialog.Close />
|
|
||||||
</Dialog.ScrollableInner>
|
|
||||||
</Dialog.Outer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inner({
|
|
||||||
control,
|
|
||||||
feed,
|
|
||||||
metricContext,
|
|
||||||
}: {
|
|
||||||
control: Dialog.DialogControlProps
|
|
||||||
feed: LiveEventFeed
|
|
||||||
metricContext: LiveEventFeedMetricContext
|
|
||||||
}) {
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {
|
|
||||||
isPending,
|
|
||||||
mutate: update,
|
|
||||||
error: rawError,
|
|
||||||
variables,
|
|
||||||
} = useUpdateLiveEventPreferences({
|
|
||||||
feed,
|
|
||||||
metricContext,
|
|
||||||
onUpdateSuccess({undoAction}) {
|
|
||||||
Toast.show(
|
|
||||||
<Toast.Outer>
|
|
||||||
<Toast.Icon />
|
|
||||||
<Toast.Text>
|
|
||||||
<Trans>Your live event preferences have been updated.</Trans>
|
|
||||||
</Toast.Text>
|
|
||||||
{undoAction && (
|
|
||||||
<Toast.Action
|
|
||||||
label={_(msg`Undo`)}
|
|
||||||
onPress={() => {
|
|
||||||
if (undoAction) {
|
|
||||||
update(undoAction)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<Trans>Undo</Trans>
|
|
||||||
</Toast.Action>
|
|
||||||
)}
|
|
||||||
</Toast.Outer>,
|
|
||||||
{type: 'success'},
|
|
||||||
)
|
|
||||||
|
|
||||||
/*
|
|
||||||
* If there is no `undoAction`, it means that the action was already
|
|
||||||
* undone, and therefore the menu would have been closed prior to the
|
|
||||||
* undo happening.
|
|
||||||
*/
|
|
||||||
if (undoAction) {
|
|
||||||
control.close()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const cleanError = useCleanError()
|
|
||||||
const error = rawError ? cleanError(rawError) : undefined
|
|
||||||
|
|
||||||
const isHidingFeed = variables?.type === 'hideFeed' && isPending
|
|
||||||
const isHidingAllFeeds = variables?.type === 'toggleHideAllFeeds' && isPending
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[a.gap_lg]}>
|
|
||||||
<View style={[a.gap_sm]}>
|
|
||||||
<Text style={[a.text_2xl, a.font_semi_bold, a.leading_snug]}>
|
|
||||||
<Trans>Live event options</Trans>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text style={[a.text_md, a.leading_snug]}>
|
|
||||||
<Trans>
|
|
||||||
Live events appear occasionally when something exciting is
|
|
||||||
happening. If you'd like, you can hide this particular event, or all
|
|
||||||
events for this placement in your app interface.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text style={[a.text_md, a.leading_snug]}>
|
|
||||||
<Trans>
|
|
||||||
If you choose to hide all events, you can always re-enable them from{' '}
|
|
||||||
<Span style={[a.font_semi_bold]}>Settings → Content & Media</Span>.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[a.gap_sm]}>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Hide this event`)}
|
|
||||||
size="large"
|
|
||||||
color="primary_subtle"
|
|
||||||
onPress={() => {
|
|
||||||
update({type: 'hideFeed', id: feed.id})
|
|
||||||
}}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Hide this event</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
{isHidingFeed && <ButtonIcon icon={Loader} />}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Hide all events`)}
|
|
||||||
size="large"
|
|
||||||
color="secondary"
|
|
||||||
onPress={() => {
|
|
||||||
update({type: 'toggleHideAllFeeds'})
|
|
||||||
}}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Hide all events</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
{isHidingAllFeeds && <ButtonIcon icon={Loader} />}
|
|
||||||
</Button>
|
|
||||||
{IS_NATIVE && (
|
|
||||||
<Button
|
|
||||||
label={_(msg`Cancel`)}
|
|
||||||
size="large"
|
|
||||||
color="secondary_inverted"
|
|
||||||
onPress={() => control.close()}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Admonition type="error">
|
|
||||||
{error.clean || error.raw || _(msg`An unknown error occurred.`)}
|
|
||||||
</Admonition>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import {msg} from '@lingui/core/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {Trans} from '@lingui/react/macro'
|
|
||||||
|
|
||||||
import * as SettingsList from '#/screens/Settings/components/SettingsList'
|
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
|
||||||
import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live'
|
|
||||||
import {
|
|
||||||
useLiveEventPreferences,
|
|
||||||
useUpdateLiveEventPreferences,
|
|
||||||
} from '#/features/liveEvents/preferences'
|
|
||||||
|
|
||||||
export function LiveEventFeedsSettingsToggle() {
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {data: prefs} = useLiveEventPreferences()
|
|
||||||
const {
|
|
||||||
isPending,
|
|
||||||
data: updatedPrefs,
|
|
||||||
mutate: update,
|
|
||||||
} = useUpdateLiveEventPreferences({
|
|
||||||
metricContext: 'settings',
|
|
||||||
})
|
|
||||||
const hideAllFeeds = !!(updatedPrefs || prefs)?.hideAllFeeds
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Toggle.Item
|
|
||||||
name="enable_live_event_banner"
|
|
||||||
label={_(msg`Show live events in your Discover Feed`)}
|
|
||||||
value={!hideAllFeeds}
|
|
||||||
onChange={() => {
|
|
||||||
if (!isPending) {
|
|
||||||
update({type: 'toggleHideAllFeeds'})
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<SettingsList.Item>
|
|
||||||
<SettingsList.ItemIcon icon={LiveIcon} />
|
|
||||||
<SettingsList.ItemText>
|
|
||||||
<Trans>Show live events in your Discover Feed</Trans>
|
|
||||||
</SettingsList.ItemText>
|
|
||||||
<Toggle.Platform />
|
|
||||||
</SettingsList.Item>
|
|
||||||
</Toggle.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import {useMemo, useState} from 'react'
|
import {useMemo, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {
|
import {type AppBskyActorDefs, type AppBskyEmbedExternal} from '@atproto/api'
|
||||||
type AppBskyActorDefs,
|
|
||||||
AppBskyActorStatus,
|
|
||||||
type AppBskyEmbedExternal,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Trans} from '@lingui/react/macro'
|
import {Trans} from '@lingui/react/macro'
|
||||||
@@ -24,6 +20,7 @@ import {Loader} from '#/components/Loader'
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {
|
import {
|
||||||
displayDuration,
|
displayDuration,
|
||||||
|
getValidLiveStatusRecord,
|
||||||
useLiveLinkMetaQuery,
|
useLiveLinkMetaQuery,
|
||||||
useRemoveLiveStatusMutation,
|
useRemoveLiveStatusMutation,
|
||||||
useUpsertLiveStatusMutation,
|
useUpsertLiveStatusMutation,
|
||||||
@@ -74,14 +71,10 @@ function DialogInner({
|
|||||||
error: linkMetaError,
|
error: linkMetaError,
|
||||||
} = useLiveLinkMetaQuery(debouncedUrl)
|
} = useLiveLinkMetaQuery(debouncedUrl)
|
||||||
|
|
||||||
const record = useMemo(() => {
|
const record = useMemo(
|
||||||
if (!AppBskyActorStatus.isRecord(status.record)) return null
|
() => getValidLiveStatusRecord(status.record),
|
||||||
const validation = AppBskyActorStatus.validateRecord(status.record)
|
[status],
|
||||||
if (validation.success) {
|
)
|
||||||
return validation.value
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}, [status])
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutate: goLive,
|
mutate: goLive,
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import * as Select from '#/components/Select'
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {
|
import {
|
||||||
displayDuration,
|
displayDuration,
|
||||||
|
getLiveLinkFromStatusRecord,
|
||||||
getLiveServiceNames,
|
getLiveServiceNames,
|
||||||
|
useActorStatus,
|
||||||
useLiveLinkMetaQuery,
|
useLiveLinkMetaQuery,
|
||||||
useLiveNowConfig,
|
useLiveNowConfig,
|
||||||
useUpsertLiveStatusMutation,
|
useUpsertLiveStatusMutation,
|
||||||
@@ -50,16 +52,20 @@ function DialogInner({profile}: {profile: bsky.profile.AnyProfileView}) {
|
|||||||
const control = Dialog.useDialogContext()
|
const control = Dialog.useDialogContext()
|
||||||
const {_, i18n} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const [liveLink, setLiveLink] = useState('')
|
|
||||||
const [liveLinkError, setLiveLinkError] = useState('')
|
const [liveLinkError, setLiveLinkError] = useState('')
|
||||||
const [duration, setDuration] = useState(60)
|
const [duration, setDuration] = useState(60)
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const tick = useTickEveryMinute()
|
const tick = useTickEveryMinute()
|
||||||
const liveNowConfig = useLiveNowConfig()
|
const liveNowConfig = useLiveNowConfig()
|
||||||
|
const status = useActorStatus(profile)
|
||||||
const {formatted: allowedServices} = getLiveServiceNames(
|
const {formatted: allowedServices} = getLiveServiceNames(
|
||||||
liveNowConfig.currentAccountAllowedHosts,
|
liveNowConfig.currentAccountAllowedHosts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const [liveLink, setLiveLink] = useState(() =>
|
||||||
|
getLiveLinkFromStatusRecord(status.record),
|
||||||
|
)
|
||||||
|
|
||||||
const time = useCallback(
|
const time = useCallback(
|
||||||
(offset: number) => {
|
(offset: number) => {
|
||||||
void tick
|
void tick
|
||||||
|
|||||||
@@ -1,7 +1,32 @@
|
|||||||
|
import {AppBskyActorStatus, AppBskyEmbedExternal} from '@atproto/api'
|
||||||
import {type I18n} from '@lingui/core'
|
import {type I18n} from '@lingui/core'
|
||||||
import {plural} from '@lingui/core/macro'
|
import {plural} from '@lingui/core/macro'
|
||||||
import psl from 'psl'
|
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) {
|
export function displayDuration(i18n: I18n, durationInMinutes: number) {
|
||||||
const roundedDurationInMinutes = Math.round(durationInMinutes)
|
const roundedDurationInMinutes = Math.round(durationInMinutes)
|
||||||
const hours = Math.floor(roundedDurationInMinutes / 60)
|
const hours = Math.floor(roundedDurationInMinutes / 60)
|
||||||
|
|||||||
@@ -1,302 +0,0 @@
|
|||||||
import {useMemo} from 'react'
|
|
||||||
|
|
||||||
import {deviceLocales} from '#/locale/deviceLocales'
|
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
|
||||||
import {useGeolocation} from '#/geolocation'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* From react-native-localize
|
|
||||||
*
|
|
||||||
* MIT License
|
|
||||||
* Copyright (c) 2017-present, Mathieu Acthernoene
|
|
||||||
*
|
|
||||||
* @see https://github.com/zoontek/react-native-localize/blob/master/LICENSE
|
|
||||||
* @see https://github.com/zoontek/react-native-localize/blob/ee5bf25e0bb8f3b8e4f3fd055f67ad46269c81ea/src/constants.ts
|
|
||||||
*/
|
|
||||||
export const countryCodeToCurrency: Record<string, string> = {
|
|
||||||
ad: 'eur',
|
|
||||||
ae: 'aed',
|
|
||||||
af: 'afn',
|
|
||||||
ag: 'xcd',
|
|
||||||
ai: 'xcd',
|
|
||||||
al: 'all',
|
|
||||||
am: 'amd',
|
|
||||||
an: 'ang',
|
|
||||||
ao: 'aoa',
|
|
||||||
ar: 'ars',
|
|
||||||
as: 'usd',
|
|
||||||
at: 'eur',
|
|
||||||
au: 'aud',
|
|
||||||
aw: 'awg',
|
|
||||||
ax: 'eur',
|
|
||||||
az: 'azn',
|
|
||||||
ba: 'bam',
|
|
||||||
bb: 'bbd',
|
|
||||||
bd: 'bdt',
|
|
||||||
be: 'eur',
|
|
||||||
bf: 'xof',
|
|
||||||
bg: 'bgn',
|
|
||||||
bh: 'bhd',
|
|
||||||
bi: 'bif',
|
|
||||||
bj: 'xof',
|
|
||||||
bl: 'eur',
|
|
||||||
bm: 'bmd',
|
|
||||||
bn: 'bnd',
|
|
||||||
bo: 'bob',
|
|
||||||
bq: 'usd',
|
|
||||||
br: 'brl',
|
|
||||||
bs: 'bsd',
|
|
||||||
bt: 'btn',
|
|
||||||
bv: 'nok',
|
|
||||||
bw: 'bwp',
|
|
||||||
by: 'byn',
|
|
||||||
bz: 'bzd',
|
|
||||||
ca: 'cad',
|
|
||||||
cc: 'aud',
|
|
||||||
cd: 'cdf',
|
|
||||||
cf: 'xaf',
|
|
||||||
cg: 'xaf',
|
|
||||||
ch: 'chf',
|
|
||||||
ci: 'xof',
|
|
||||||
ck: 'nzd',
|
|
||||||
cl: 'clp',
|
|
||||||
cm: 'xaf',
|
|
||||||
cn: 'cny',
|
|
||||||
co: 'cop',
|
|
||||||
cr: 'crc',
|
|
||||||
cu: 'cup',
|
|
||||||
cv: 'cve',
|
|
||||||
cw: 'ang',
|
|
||||||
cx: 'aud',
|
|
||||||
cy: 'eur',
|
|
||||||
cz: 'czk',
|
|
||||||
de: 'eur',
|
|
||||||
dj: 'djf',
|
|
||||||
dk: 'dkk',
|
|
||||||
dm: 'xcd',
|
|
||||||
do: 'dop',
|
|
||||||
dz: 'dzd',
|
|
||||||
ec: 'usd',
|
|
||||||
ee: 'eur',
|
|
||||||
eg: 'egp',
|
|
||||||
eh: 'mad',
|
|
||||||
er: 'ern',
|
|
||||||
es: 'eur',
|
|
||||||
et: 'etb',
|
|
||||||
fi: 'eur',
|
|
||||||
fj: 'fjd',
|
|
||||||
fk: 'fkp',
|
|
||||||
fm: 'usd',
|
|
||||||
fo: 'dkk',
|
|
||||||
fr: 'eur',
|
|
||||||
ga: 'xaf',
|
|
||||||
gb: 'gbp',
|
|
||||||
gd: 'xcd',
|
|
||||||
ge: 'gel',
|
|
||||||
gf: 'eur',
|
|
||||||
gg: 'gbp',
|
|
||||||
gh: 'ghs',
|
|
||||||
gi: 'gip',
|
|
||||||
gl: 'dkk',
|
|
||||||
gm: 'gmd',
|
|
||||||
gn: 'gnf',
|
|
||||||
gp: 'eur',
|
|
||||||
gq: 'xaf',
|
|
||||||
gr: 'eur',
|
|
||||||
gs: 'gbp',
|
|
||||||
gt: 'gtq',
|
|
||||||
gu: 'usd',
|
|
||||||
gw: 'xof',
|
|
||||||
gy: 'gyd',
|
|
||||||
hk: 'hkd',
|
|
||||||
hm: 'aud',
|
|
||||||
hn: 'hnl',
|
|
||||||
hr: 'hrk',
|
|
||||||
ht: 'htg',
|
|
||||||
hu: 'huf',
|
|
||||||
id: 'idr',
|
|
||||||
ie: 'eur',
|
|
||||||
il: 'ils',
|
|
||||||
im: 'gbp',
|
|
||||||
in: 'inr',
|
|
||||||
io: 'usd',
|
|
||||||
iq: 'iqd',
|
|
||||||
ir: 'irr',
|
|
||||||
is: 'isk',
|
|
||||||
it: 'eur',
|
|
||||||
je: 'gbp',
|
|
||||||
jm: 'jmd',
|
|
||||||
jo: 'jod',
|
|
||||||
jp: 'jpy',
|
|
||||||
ke: 'kes',
|
|
||||||
kg: 'kgs',
|
|
||||||
kh: 'khr',
|
|
||||||
ki: 'aud',
|
|
||||||
km: 'kmf',
|
|
||||||
kn: 'xcd',
|
|
||||||
kp: 'kpw',
|
|
||||||
kr: 'krw',
|
|
||||||
kw: 'kwd',
|
|
||||||
ky: 'kyd',
|
|
||||||
kz: 'kzt',
|
|
||||||
la: 'lak',
|
|
||||||
lb: 'lbp',
|
|
||||||
lc: 'xcd',
|
|
||||||
li: 'chf',
|
|
||||||
lk: 'lkr',
|
|
||||||
lr: 'lrd',
|
|
||||||
ls: 'lsl',
|
|
||||||
lt: 'eur',
|
|
||||||
lu: 'eur',
|
|
||||||
lv: 'eur',
|
|
||||||
ly: 'lyd',
|
|
||||||
ma: 'mad',
|
|
||||||
mc: 'eur',
|
|
||||||
md: 'mdl',
|
|
||||||
me: 'eur',
|
|
||||||
mf: 'eur',
|
|
||||||
mg: 'mga',
|
|
||||||
mh: 'usd',
|
|
||||||
mk: 'mkd',
|
|
||||||
ml: 'xof',
|
|
||||||
mm: 'mmk',
|
|
||||||
mn: 'mnt',
|
|
||||||
mo: 'mop',
|
|
||||||
mp: 'usd',
|
|
||||||
mq: 'eur',
|
|
||||||
mr: 'mro',
|
|
||||||
ms: 'xcd',
|
|
||||||
mt: 'eur',
|
|
||||||
mu: 'mur',
|
|
||||||
mv: 'mvr',
|
|
||||||
mw: 'mwk',
|
|
||||||
mx: 'mxn',
|
|
||||||
my: 'myr',
|
|
||||||
mz: 'mzn',
|
|
||||||
na: 'nad',
|
|
||||||
nc: 'xpf',
|
|
||||||
ne: 'xof',
|
|
||||||
nf: 'aud',
|
|
||||||
ng: 'ngn',
|
|
||||||
ni: 'nio',
|
|
||||||
nl: 'eur',
|
|
||||||
no: 'nok',
|
|
||||||
np: 'npr',
|
|
||||||
nr: 'aud',
|
|
||||||
nu: 'nzd',
|
|
||||||
nz: 'nzd',
|
|
||||||
om: 'omr',
|
|
||||||
pa: 'pab',
|
|
||||||
pe: 'pen',
|
|
||||||
pf: 'xpf',
|
|
||||||
pg: 'pgk',
|
|
||||||
ph: 'php',
|
|
||||||
pk: 'pkr',
|
|
||||||
pl: 'pln',
|
|
||||||
pm: 'eur',
|
|
||||||
pn: 'nzd',
|
|
||||||
pr: 'usd',
|
|
||||||
ps: 'ils',
|
|
||||||
pt: 'eur',
|
|
||||||
pw: 'usd',
|
|
||||||
py: 'pyg',
|
|
||||||
qa: 'qar',
|
|
||||||
re: 'eur',
|
|
||||||
ro: 'ron',
|
|
||||||
rs: 'rsd',
|
|
||||||
ru: 'rub',
|
|
||||||
rw: 'rwf',
|
|
||||||
sa: 'sar',
|
|
||||||
sb: 'sbd',
|
|
||||||
sc: 'scr',
|
|
||||||
sd: 'sdg',
|
|
||||||
se: 'sek',
|
|
||||||
sg: 'sgd',
|
|
||||||
sh: 'shp',
|
|
||||||
si: 'eur',
|
|
||||||
sj: 'nok',
|
|
||||||
sk: 'eur',
|
|
||||||
sl: 'sll',
|
|
||||||
sm: 'eur',
|
|
||||||
sn: 'xof',
|
|
||||||
so: 'sos',
|
|
||||||
sr: 'srd',
|
|
||||||
ss: 'ssp',
|
|
||||||
st: 'std',
|
|
||||||
sv: 'svc',
|
|
||||||
sx: 'ang',
|
|
||||||
sy: 'syp',
|
|
||||||
sz: 'szl',
|
|
||||||
tc: 'usd',
|
|
||||||
td: 'xaf',
|
|
||||||
tf: 'eur',
|
|
||||||
tg: 'xof',
|
|
||||||
th: 'thb',
|
|
||||||
tj: 'tjs',
|
|
||||||
tk: 'nzd',
|
|
||||||
tl: 'usd',
|
|
||||||
tm: 'tmt',
|
|
||||||
tn: 'tnd',
|
|
||||||
to: 'top',
|
|
||||||
tr: 'try',
|
|
||||||
tt: 'ttd',
|
|
||||||
tv: 'aud',
|
|
||||||
tw: 'twd',
|
|
||||||
tz: 'tzs',
|
|
||||||
ua: 'uah',
|
|
||||||
ug: 'ugx',
|
|
||||||
um: 'usd',
|
|
||||||
us: 'usd',
|
|
||||||
uy: 'uyu',
|
|
||||||
uz: 'uzs',
|
|
||||||
va: 'eur',
|
|
||||||
vc: 'xcd',
|
|
||||||
ve: 'vef',
|
|
||||||
vg: 'usd',
|
|
||||||
vi: 'usd',
|
|
||||||
vn: 'vnd',
|
|
||||||
vu: 'vuv',
|
|
||||||
wf: 'xpf',
|
|
||||||
ws: 'wst',
|
|
||||||
ye: 'yer',
|
|
||||||
yt: 'eur',
|
|
||||||
za: 'zar',
|
|
||||||
zm: 'zmw',
|
|
||||||
zw: 'zwl',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-guess currency formatting.
|
|
||||||
*
|
|
||||||
* Attempts to use `getLocales` from `expo-localization` if available,
|
|
||||||
* otherwise falls back to the `persisted.appLanguage` setting, and geolocation
|
|
||||||
* API for region.
|
|
||||||
*/
|
|
||||||
export function useFormatCurrency(
|
|
||||||
options?: Parameters<typeof Intl.NumberFormat>[1],
|
|
||||||
) {
|
|
||||||
const geolocation = useGeolocation()
|
|
||||||
const {appLanguage} = useLanguagePrefs()
|
|
||||||
return useMemo(() => {
|
|
||||||
const locale = deviceLocales.at(0)
|
|
||||||
const languageTag = locale?.languageTag || appLanguage || 'en-US'
|
|
||||||
const countryCode = (
|
|
||||||
locale?.regionCode ||
|
|
||||||
geolocation?.countryCode ||
|
|
||||||
'us'
|
|
||||||
).toLowerCase()
|
|
||||||
const currency = countryCodeToCurrency[countryCode] || 'usd'
|
|
||||||
const format = new Intl.NumberFormat(languageTag, {
|
|
||||||
...(options || {}),
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
}).format
|
|
||||||
|
|
||||||
return {
|
|
||||||
format,
|
|
||||||
currency,
|
|
||||||
countryCode,
|
|
||||||
languageTag,
|
|
||||||
}
|
|
||||||
}, [geolocation, appLanguage, options])
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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])
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import {useCallback, useEffect, useRef} from 'react'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper hook to run persistent timers on views
|
|
||||||
*/
|
|
||||||
export function useTimer(time: number, handler: () => void) {
|
|
||||||
const timer = useRef<undefined | NodeJS.Timeout>(undefined)
|
|
||||||
|
|
||||||
// function to restart the timer
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
if (timer.current) {
|
|
||||||
clearTimeout(timer.current)
|
|
||||||
}
|
|
||||||
timer.current = setTimeout(handler, time)
|
|
||||||
}, [time, timer, handler])
|
|
||||||
|
|
||||||
// function to cancel the timer
|
|
||||||
const cancel = useCallback(() => {
|
|
||||||
if (timer.current) {
|
|
||||||
clearTimeout(timer.current)
|
|
||||||
timer.current = undefined
|
|
||||||
}
|
|
||||||
}, [timer])
|
|
||||||
|
|
||||||
// start the timer immediately
|
|
||||||
useEffect(() => {
|
|
||||||
reset()
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return [reset, cancel]
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,9 @@ import {getVideoMetaData, Video} from 'react-native-compressor'
|
|||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
|
||||||
import {type CompressedVideo} from './types'
|
import {logger} from '#/logger'
|
||||||
|
import {probe} from '../../../../modules/expo-bluesky-video-compress'
|
||||||
|
import {type CompressedVideo, type ProbedMetadata} from './types'
|
||||||
import {extToMime} from './util'
|
import {extToMime} from './util'
|
||||||
|
|
||||||
const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
|
const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
|
||||||
@@ -12,9 +14,20 @@ export async function compressVideo(
|
|||||||
opts?: {
|
opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
|
onProbe?: (metadata: ProbedMetadata) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {onProgress, signal} = opts || {}
|
const {onProgress, signal, onProbe} = opts || {}
|
||||||
|
|
||||||
|
// Probe data is purely informational - fired into telemetry to validate
|
||||||
|
// future smart-skip thresholds. Failures must not block the upload.
|
||||||
|
if (onProbe && file.mimeType !== 'image/gif') {
|
||||||
|
try {
|
||||||
|
onProbe(await probe(file.uri))
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('video probe failed', {safeMessage: e})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (file.mimeType === 'image/gif') {
|
if (file.mimeType === 'image/gif') {
|
||||||
// let's hope they're small enough that they don't need compression!
|
// let's hope they're small enough that they don't need compression!
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
COMPRESSION_MIN_SIZE_BYTES,
|
COMPRESSION_MIN_SIZE_BYTES,
|
||||||
COMPRESSION_TARGET_BITRATE,
|
COMPRESSION_TARGET_BITRATE,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import {type CompressedVideo} from './types'
|
import {type CompressedVideo, type ProbedMetadata} from './types'
|
||||||
|
|
||||||
// Codecs to try in order of preference
|
// Codecs to try in order of preference
|
||||||
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
|
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
|
||||||
@@ -34,9 +34,10 @@ export async function compressVideo(
|
|||||||
opts?: {
|
opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
|
onProbe?: (metadata: ProbedMetadata) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {onProgress, signal} = opts || {}
|
const {onProgress, signal, onProbe} = opts || {}
|
||||||
|
|
||||||
logger.debug('compress: starting', {
|
logger.debug('compress: starting', {
|
||||||
uri: asset.uri.slice(0, 50),
|
uri: asset.uri.slice(0, 50),
|
||||||
@@ -49,6 +50,18 @@ export async function compressVideo(
|
|||||||
const isGif = blob.type === 'image/gif'
|
const isGif = blob.type === 'image/gif'
|
||||||
const hasCodecs = hasWebCodecs()
|
const hasCodecs = hasWebCodecs()
|
||||||
|
|
||||||
|
// Probe pass: extract source metadata via mediabunny before any compression
|
||||||
|
// decision. Fires before doCompression so the probed event lands ahead of
|
||||||
|
// compressCompleted/compressSkipped in the funnel. GIFs and missing
|
||||||
|
// WebCodecs both skip - mediabunny needs a parseable container + decoder.
|
||||||
|
if (onProbe && !isGif && hasCodecs) {
|
||||||
|
try {
|
||||||
|
onProbe(await probeWithMediaBunny(blob))
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('video probe failed', {safeMessage: e})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug('compress: fetched blob', {
|
logger.debug('compress: fetched blob', {
|
||||||
size: blob.size,
|
size: blob.size,
|
||||||
mimeType: blob.type,
|
mimeType: blob.type,
|
||||||
@@ -98,6 +111,44 @@ export async function compressVideo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function probeWithMediaBunny(blob: Blob): Promise<ProbedMetadata> {
|
||||||
|
const input = new Input({source: new BlobSource(blob), formats: ALL_FORMATS})
|
||||||
|
try {
|
||||||
|
const videoTrack = await input.getPrimaryVideoTrack()
|
||||||
|
if (!videoTrack) {
|
||||||
|
throw new Error('No video track found')
|
||||||
|
}
|
||||||
|
const audioTrack = await input.getPrimaryAudioTrack()
|
||||||
|
const [codec, codedWidth, codedHeight, rotation, isHDR, stats, duration] =
|
||||||
|
await Promise.all([
|
||||||
|
videoTrack.getCodec(),
|
||||||
|
videoTrack.getCodedWidth(),
|
||||||
|
videoTrack.getCodedHeight(),
|
||||||
|
videoTrack.getRotation(),
|
||||||
|
videoTrack.hasHighDynamicRange(),
|
||||||
|
// Sample a small fixed slice instead of scanning the whole file - we
|
||||||
|
// only want an approximate bitrate / frame rate for telemetry.
|
||||||
|
videoTrack.computePacketStats(100),
|
||||||
|
input.computeDuration(),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
mimeType: blob.type || 'video/mp4',
|
||||||
|
codec: codec ?? 'unknown',
|
||||||
|
width: codedWidth,
|
||||||
|
height: codedHeight,
|
||||||
|
duration,
|
||||||
|
bitrate: Math.round(stats.averageBitrate),
|
||||||
|
fileSize: blob.size,
|
||||||
|
hasAudio: audioTrack !== null,
|
||||||
|
frameRate: stats.averagePacketRate,
|
||||||
|
rotation,
|
||||||
|
isHDR,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
input.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function findEncodableVideoCodec(
|
async function findEncodableVideoCodec(
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// Shared compression knobs. Mirrored between native (compress.ts) and web
|
// Shared compression knobs. Mirrored between native (compress.ts) and web
|
||||||
// (compress.web.ts) so both platforms produce videos with the same target.
|
// (compress.web.ts) so both platforms produce videos with the same target.
|
||||||
|
|
||||||
|
// Target encode bitrate when we do compress.
|
||||||
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps
|
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps
|
||||||
|
// Output dimension cap when compressing, and skip threshold for source files.
|
||||||
export const COMPRESSION_MAX_DIMENSION = 1920
|
export const COMPRESSION_MAX_DIMENSION = 1920
|
||||||
// Web skips compression entirely for files under this size; native applies its
|
// Web only: files under this size skip compression entirely. Native applies
|
||||||
// own threshold logic inside expo-bluesky-video-compress's probe step.
|
// its own threshold logic inside react-native-compressor.
|
||||||
export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000
|
export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import {Platform} from 'react-native'
|
|||||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
import {nanoid} from 'nanoid/non-secure'
|
||||||
|
|
||||||
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
|
import {
|
||||||
|
type ProbedMetadata,
|
||||||
|
type VideoCompressSkipReason,
|
||||||
|
} from '#/lib/media/video/types'
|
||||||
import {Sentry} from '#/logger/sentry/lib'
|
import {Sentry} from '#/logger/sentry/lib'
|
||||||
import {type Metrics} from '#/analytics/metrics'
|
import {type Metrics} from '#/analytics/metrics'
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ export type VideoTelemetry = {
|
|||||||
readonly engine: string
|
readonly engine: string
|
||||||
picked: () => void
|
picked: () => void
|
||||||
compressStarted: () => void
|
compressStarted: () => void
|
||||||
|
probed: (metadata: ProbedMetadata) => void
|
||||||
compressSkipped: (video: {
|
compressSkipped: (video: {
|
||||||
size: number
|
size: number
|
||||||
mimeType: string
|
mimeType: string
|
||||||
@@ -155,6 +159,24 @@ export function createVideoTelemetry({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
probed(metadata) {
|
||||||
|
metric('video:upload:probed', {
|
||||||
|
uploadId,
|
||||||
|
engine,
|
||||||
|
mimeType: metadata.mimeType,
|
||||||
|
codec: metadata.codec,
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height,
|
||||||
|
duration: metadata.duration,
|
||||||
|
bitrate: metadata.bitrate,
|
||||||
|
fileSize: metadata.fileSize,
|
||||||
|
hasAudio: metadata.hasAudio,
|
||||||
|
frameRate: metadata.frameRate,
|
||||||
|
rotation: metadata.rotation,
|
||||||
|
isHDR: metadata.isHDR,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
compressSkipped({size, mimeType, skipReason}) {
|
compressSkipped({size, mimeType, skipReason}) {
|
||||||
metric('video:upload:compressSkipped', {
|
metric('video:upload:compressSkipped', {
|
||||||
uploadId,
|
uploadId,
|
||||||
|
|||||||
@@ -18,3 +18,20 @@ export type CompressedVideo = {
|
|||||||
// bytes were actually re-encoded.
|
// bytes were actually re-encoded.
|
||||||
passthroughReason?: VideoCompressSkipReason
|
passthroughReason?: VideoCompressSkipReason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source container metadata read off the input before any encoding decision.
|
||||||
|
// Same shape across native (expo-bluesky-video-compress probe) and web
|
||||||
|
// (mediabunny Input + track inspection). Numbers are raw - no bucketing.
|
||||||
|
export type ProbedMetadata = {
|
||||||
|
mimeType: string
|
||||||
|
codec: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
duration: number
|
||||||
|
bitrate: number
|
||||||
|
fileSize: number
|
||||||
|
hasAudio: boolean
|
||||||
|
frameRate: number
|
||||||
|
rotation: number
|
||||||
|
isHDR: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ export function useNotificationsRegistration() {
|
|||||||
}, [currentAccount, getAndRegisterPushToken, registerPushToken, aa])
|
}, [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() {
|
export function useRequestNotificationsPermission() {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -249,7 +257,31 @@ export function useRequestNotificationsPermission() {
|
|||||||
return
|
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`, {
|
ax.metric(`notifications:request`, {
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import {
|
|||||||
useNavigation,
|
useNavigation,
|
||||||
} from '@react-navigation/native'
|
} from '@react-navigation/native'
|
||||||
|
|
||||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
|
||||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||||
import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
|
import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
|
||||||
import {
|
import {
|
||||||
type CommonNavigatorParams,
|
type CommonNavigatorParams,
|
||||||
type NativeStackScreenProps,
|
type NativeStackScreenProps,
|
||||||
} from '#/lib/routes/types'
|
} from '#/lib/routes/types'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
|
import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation'
|
||||||
import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery'
|
import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery'
|
||||||
import {Post} from '#/view/com/post/Post'
|
import {Post} from '#/view/com/post/Post'
|
||||||
@@ -92,7 +92,6 @@ type ListItem =
|
|||||||
|
|
||||||
function BookmarksInner() {
|
function BookmarksInner() {
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
const cleanError = useCleanError()
|
|
||||||
const [isPTRing, setIsPTRing] = useState(false)
|
const [isPTRing, setIsPTRing] = useState(false)
|
||||||
const trackPostView = usePostViewTracking('Bookmarks')
|
const trackPostView = usePostViewTracking('Bookmarks')
|
||||||
const {
|
const {
|
||||||
@@ -104,10 +103,6 @@ function BookmarksInner() {
|
|||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useBookmarksQuery()
|
} = useBookmarksQuery()
|
||||||
const cleanedError = useMemo(() => {
|
|
||||||
const {raw, clean} = cleanError(error)
|
|
||||||
return clean || raw
|
|
||||||
}, [error, cleanError])
|
|
||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setIsPTRing(true)
|
setIsPTRing(true)
|
||||||
@@ -174,10 +169,10 @@ function BookmarksInner() {
|
|||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
refreshing={isPTRing}
|
refreshing={isPTRing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={() => void onRefresh()}
|
||||||
onEndReached={onEndReached}
|
onEndReached={() => void onEndReached()}
|
||||||
onEndReachedThreshold={4}
|
onEndReachedThreshold={4}
|
||||||
onItemSeen={item => {
|
onItemSeen={(item: ListItem) => {
|
||||||
if (item.type === 'bookmark') {
|
if (item.type === 'bookmark') {
|
||||||
trackPostView(item.bookmark.item)
|
trackPostView(item.bookmark.item)
|
||||||
}
|
}
|
||||||
@@ -185,7 +180,7 @@ function BookmarksInner() {
|
|||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
<ListFooter
|
<ListFooter
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
error={cleanedError}
|
error={cleanError(error)}
|
||||||
onRetry={fetchNextPage}
|
onRetry={fetchNextPage}
|
||||||
style={[isEmpty && a.border_t_0]}
|
style={[isEmpty && a.border_t_0]}
|
||||||
/>
|
/>
|
||||||
@@ -209,7 +204,6 @@ function BookmarkNotFound({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {mutateAsync: bookmark} = useBookmarkMutation()
|
const {mutateAsync: bookmark} = useBookmarkMutation()
|
||||||
const cleanError = useCleanError()
|
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -217,9 +211,8 @@ function BookmarkNotFound({
|
|||||||
toast.show(_(msg`Removed from saved posts`), {
|
toast.show(_(msg`Removed from saved posts`), {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (err) {
|
||||||
const {raw, clean} = cleanError(e)
|
toast.show(cleanError(err), {
|
||||||
toast.show(clean || raw || e, {
|
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -259,7 +252,7 @@ function BookmarkNotFound({
|
|||||||
label={_(msg`Remove from saved posts`)}
|
label={_(msg`Remove from saved posts`)}
|
||||||
size="tiny"
|
size="tiny"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
onPress={remove}>
|
onPress={() => void remove()}>
|
||||||
<ButtonIcon icon={BookmarkFilled} />
|
<ButtonIcon icon={BookmarkFilled} />
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Remove</Trans>
|
<Trans>Remove</Trans>
|
||||||
@@ -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 (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.align_center,
|
|
||||||
{
|
|
||||||
paddingVertical: 64,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<BookmarkDeleteLarge
|
|
||||||
width={64}
|
|
||||||
fill={t.atoms.text_contrast_medium.color}
|
|
||||||
/>
|
|
||||||
<View style={[a.pt_sm]}>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.text_lg,
|
|
||||||
a.font_medium,
|
|
||||||
a.text_center,
|
|
||||||
t.atoms.text_contrast_medium,
|
|
||||||
]}>
|
|
||||||
<Trans>Nothing saved yet</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={[a.pt_2xl]}>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
action="navigate"
|
|
||||||
label={_(
|
|
||||||
msg({
|
|
||||||
message: `Go home`,
|
|
||||||
context: `Button to go back to the home timeline`,
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
size="small"
|
|
||||||
color="secondary">
|
|
||||||
<ButtonText>
|
|
||||||
<Trans context="Button to go back to the home timeline">
|
|
||||||
Go home
|
|
||||||
</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Link>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -121,7 +121,9 @@ export default function HashtagScreen({
|
|||||||
<Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText>
|
<Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText>
|
||||||
{author && (
|
{author && (
|
||||||
<Layout.Header.SubtitleText>
|
<Layout.Header.SubtitleText>
|
||||||
{_(msg`From @${sanitizedAuthor}`)}
|
{author.startsWith('did:')
|
||||||
|
? _(msg`From ${sanitizedAuthor}`)
|
||||||
|
: _(msg`From @${sanitizedAuthor}`)}
|
||||||
</Layout.Header.SubtitleText>
|
</Layout.Header.SubtitleText>
|
||||||
)}
|
)}
|
||||||
</Layout.Header.Content>
|
</Layout.Header.Content>
|
||||||
@@ -172,11 +174,9 @@ function HashtagScreenTab({
|
|||||||
const isCashtag = fullTag.startsWith('$')
|
const isCashtag = fullTag.startsWith('$')
|
||||||
|
|
||||||
const queryParam = useMemo(() => {
|
const queryParam = useMemo(() => {
|
||||||
// Cashtags need # prefix for search: "#$BTC" or "#$BTC from:author"
|
// Cashtags need # prefix for search: "#$BTC"
|
||||||
const searchTag = isCashtag ? `#${fullTag}` : fullTag
|
return isCashtag ? `#${fullTag}` : fullTag
|
||||||
if (!author) return searchTag
|
}, [fullTag, isCashtag])
|
||||||
return `${searchTag} from:${author}`
|
|
||||||
}, [fullTag, author, isCashtag])
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -188,7 +188,7 @@ function HashtagScreenTab({
|
|||||||
refetch,
|
refetch,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
} = useSearchPostsQuery({query: queryParam, sort, enabled: active})
|
} = useSearchPostsQuery({query: queryParam, sort, enabled: active, author})
|
||||||
|
|
||||||
const posts = useMemo(() => {
|
const posts = useMemo(() => {
|
||||||
return data?.pages.flatMap(page => page.posts) || []
|
return data?.pages.flatMap(page => page.posts) || []
|
||||||
|
|||||||
+2
-9
@@ -60,7 +60,7 @@ export function LogScreen({}: NativeStackScreenProps<
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.align_center,
|
a.align_start,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
a.px_sm,
|
a.px_sm,
|
||||||
a.border_b,
|
a.border_b,
|
||||||
@@ -78,14 +78,7 @@ export function LogScreen({}: NativeStackScreenProps<
|
|||||||
) : (
|
) : (
|
||||||
<CircleInfoIcon size="sm" />
|
<CircleInfoIcon size="sm" />
|
||||||
)}
|
)}
|
||||||
<View
|
<View style={[a.flex_1, a.gap_sm]}>
|
||||||
style={[
|
|
||||||
a.flex_1,
|
|
||||||
a.flex_row,
|
|
||||||
a.justify_start,
|
|
||||||
a.align_center,
|
|
||||||
a.gap_sm,
|
|
||||||
]}>
|
|
||||||
{entry.context && (
|
{entry.context && (
|
||||||
<Text style={[t.atoms.text_contrast_medium]}>
|
<Text style={[t.atoms.text_contrast_medium]}>
|
||||||
({String(entry.context)})
|
({String(entry.context)})
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
|||||||
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
||||||
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||||
import {DateDivider} from '#/components/dms/DateDivider'
|
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 {MessageOverlays} from '#/components/dms/MessageOverlays'
|
||||||
import {MessageRepliesProvider} from '#/components/dms/MessageReplies'
|
import {MessageRepliesProvider} from '#/components/dms/MessageReplies'
|
||||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||||
@@ -104,7 +107,7 @@ function keyExtractor(item: RenderItem) {
|
|||||||
function getNeighborMessage(
|
function getNeighborMessage(
|
||||||
items: RenderItem[],
|
items: RenderItem[],
|
||||||
index: number,
|
index: number,
|
||||||
): ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null {
|
): MessageItemNeighbor {
|
||||||
const neighbor = items[index]
|
const neighbor = items[index]
|
||||||
if (!neighbor) return null
|
if (!neighbor) return null
|
||||||
if (
|
if (
|
||||||
@@ -200,6 +203,21 @@ export function MessagesList({
|
|||||||
// the bottom.
|
// the bottom.
|
||||||
const isAtBottom = useSharedValue(true)
|
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
|
// This will be used on web to assist in determining if we need to maintain the content offset
|
||||||
const isAtTop = useSharedValue(true)
|
const isAtTop = useSharedValue(true)
|
||||||
|
|
||||||
@@ -220,6 +238,12 @@ export function MessagesList({
|
|||||||
if (prevHasScrolled.current && !hasScrolled) {
|
if (prevHasScrolled.current && !hasScrolled) {
|
||||||
hasInitiallyScrolled.current = false
|
hasInitiallyScrolled.current = false
|
||||||
setDidInitialScroll(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
|
prevHasScrolled.current = hasScrolled
|
||||||
}, [hasScrolled])
|
}, [hasScrolled])
|
||||||
@@ -253,6 +277,59 @@ export function MessagesList({
|
|||||||
}
|
}
|
||||||
}, [convoState.status])
|
}, [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
|
// -- Scroll handling
|
||||||
|
|
||||||
// Every time the content size changes, that means one of two things is happening:
|
// Every time the content size changes, that means one of two things is happening:
|
||||||
@@ -479,6 +556,11 @@ export function MessagesList({
|
|||||||
setHasScrolled(true)
|
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(
|
convoState.sendMessage(
|
||||||
{
|
{
|
||||||
text: rt.text,
|
text: rt.text,
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api'
|
|||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {type Shadow} from '#/state/cache/types'
|
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'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function ProfileHeaderDisplayName({
|
export function ProfileHeaderDisplayName({
|
||||||
@@ -18,7 +19,7 @@ export function ProfileHeaderDisplayName({
|
|||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View pointerEvents="none">
|
<View>
|
||||||
<Text
|
<Text
|
||||||
emoji
|
emoji
|
||||||
testID="profileHeaderDisplayName"
|
testID="profileHeaderDisplayName"
|
||||||
@@ -27,11 +28,15 @@ export function ProfileHeaderDisplayName({
|
|||||||
gtMobile ? a.text_4xl : a.text_3xl,
|
gtMobile ? a.text_4xl : a.text_3xl,
|
||||||
a.self_start,
|
a.self_start,
|
||||||
a.font_bold,
|
a.font_bold,
|
||||||
|
a.leading_tight,
|
||||||
]}>
|
]}>
|
||||||
{sanitizeDisplayName(
|
{sanitizeDisplayName(
|
||||||
profile.displayName || sanitizeHandle(profile.handle),
|
profile.displayName || sanitizeHandle(profile.handle),
|
||||||
moderation.ui('displayName'),
|
moderation.ui('displayName'),
|
||||||
)}
|
)}
|
||||||
|
<View style={[a.pl_xs, {marginTop: platform({ios: 2})}]}>
|
||||||
|
<ProfileBadges profile={profile} size="lg" interactive />
|
||||||
|
</View>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {Trans} from '@lingui/react/macro'
|
|||||||
|
|
||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
|
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +21,7 @@ import {
|
|||||||
} from '#/state/queries/profile'
|
} from '#/state/queries/profile'
|
||||||
import {useRequireAuth, useSession} from '#/state/session'
|
import {useRequireAuth, useSession} from '#/state/session'
|
||||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
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 {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
|
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
|
||||||
@@ -34,16 +33,15 @@ import {
|
|||||||
KnownFollowers,
|
KnownFollowers,
|
||||||
shouldShowKnownFollowers,
|
shouldShowKnownFollowers,
|
||||||
} from '#/components/KnownFollowers'
|
} from '#/components/KnownFollowers'
|
||||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_IOS, IS_NATIVE} from '#/env'
|
import {IS_IOS, IS_NATIVE} from '#/env'
|
||||||
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
import {InviteFriendsDialog} from '#/features/inviteFriends'
|
||||||
import {useActorStatus} from '#/features/liveNow'
|
import {useActorStatus} from '#/features/liveNow'
|
||||||
import {GermButton} from '../components/GermButton'
|
import {GermButton} from '../components/GermButton'
|
||||||
|
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||||
import {EditProfileDialog} from './EditProfileDialog'
|
import {EditProfileDialog} from './EditProfileDialog'
|
||||||
import {ProfileHeaderHandle} from './Handle'
|
import {ProfileHeaderHandle} from './Handle'
|
||||||
import {ProfileHeaderMetrics} from './Metrics'
|
import {ProfileHeaderMetrics} from './Metrics'
|
||||||
@@ -65,8 +63,6 @@ let ProfileHeaderStandard = ({
|
|||||||
hideBackButton = false,
|
hideBackButton = false,
|
||||||
isPlaceholderProfile,
|
isPlaceholderProfile,
|
||||||
}: Props): React.ReactNode => {
|
}: Props): React.ReactNode => {
|
||||||
const t = useTheme()
|
|
||||||
const {gtMobile} = useBreakpoints()
|
|
||||||
const profile =
|
const profile =
|
||||||
useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed)
|
useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed)
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -138,26 +134,10 @@ let ProfileHeaderStandard = ({
|
|||||||
</View>
|
</View>
|
||||||
<View
|
<View
|
||||||
style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
|
style={[a.flex_col, a.gap_xs, a.pb_sm, live ? a.pt_sm : a.pt_2xs]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs, a.flex_1]}>
|
<ProfileHeaderDisplayName
|
||||||
<Text
|
profile={profile}
|
||||||
emoji
|
moderation={moderation}
|
||||||
testID="profileHeaderDisplayName"
|
/>
|
||||||
style={[
|
|
||||||
t.atoms.text,
|
|
||||||
gtMobile ? a.text_4xl : a.text_3xl,
|
|
||||||
a.self_start,
|
|
||||||
a.font_bold,
|
|
||||||
a.leading_tight,
|
|
||||||
]}>
|
|
||||||
{sanitizeDisplayName(
|
|
||||||
profile.displayName || sanitizeHandle(profile.handle),
|
|
||||||
moderation.ui('displayName'),
|
|
||||||
)}
|
|
||||||
<View style={[a.pl_xs, {marginTop: platform({ios: 2})}]}>
|
|
||||||
<ProfileBadges profile={profile} size="lg" interactive />
|
|
||||||
</View>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<ProfileHeaderHandle profile={profile} />
|
<ProfileHeaderHandle profile={profile} />
|
||||||
</View>
|
</View>
|
||||||
{!isPlaceholderProfile && !isBlockedUser && (
|
{!isPlaceholderProfile && !isBlockedUser && (
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
|||||||
import {MagnifyingGlassIcon} from '#/lib/icons'
|
import {MagnifyingGlassIcon} from '#/lib/icons'
|
||||||
import {type NavigationProp, type SearchParams} from '#/lib/routes/types'
|
import {type NavigationProp, type SearchParams} from '#/lib/routes/types'
|
||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
|
||||||
import {
|
import {
|
||||||
unstableCacheProfileView,
|
unstableCacheProfileView,
|
||||||
useProfilesQuery,
|
useProfilesQuery,
|
||||||
@@ -43,8 +42,8 @@ import {
|
|||||||
} from '#/screens/Search/searchParams'
|
} from '#/screens/Search/searchParams'
|
||||||
import {makeSearchQuery} from '#/screens/Search/utils'
|
import {makeSearchQuery} from '#/screens/Search/utils'
|
||||||
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
|
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
|
||||||
|
import {useAutocomplete} from '#/components/Autocomplete'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {SearchInput} from '#/components/forms/SearchInput'
|
|
||||||
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
|
||||||
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
@@ -57,6 +56,7 @@ import type * as bsky from '#/types/bsky'
|
|||||||
import {AdvancedSearchDialog} from './components/AdvancedSearchDialog'
|
import {AdvancedSearchDialog} from './components/AdvancedSearchDialog'
|
||||||
import {AutocompleteResults} from './components/AutocompleteResults'
|
import {AutocompleteResults} from './components/AutocompleteResults'
|
||||||
import {DetectedLanguagesAdmonition} from './components/DetectedLanguagesAdmonition'
|
import {DetectedLanguagesAdmonition} from './components/DetectedLanguagesAdmonition'
|
||||||
|
import {SearchAutocompleteInput} from './components/SearchAutocompleteInput'
|
||||||
import {SearchHistory} from './components/SearchHistory'
|
import {SearchHistory} from './components/SearchHistory'
|
||||||
import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
|
import {SearchLanguageDropdown} from './components/SearchLanguageDropdown'
|
||||||
import {Explore} from './Explore'
|
import {Explore} from './Explore'
|
||||||
@@ -125,8 +125,16 @@ export function SearchScreenShell({
|
|||||||
setSearchText(text)
|
setSearchText(text)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const {data: autocompleteData, isFetching: isAutocompleteFetching} =
|
const {items: autocompleteItems, isFetching: isAutocompleteFetching} =
|
||||||
useActorAutocompleteQuery(searchText, true)
|
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)
|
const [showAutocomplete, setShowAutocomplete] = useState(false)
|
||||||
|
|
||||||
@@ -388,6 +396,38 @@ export function SearchScreenShell({
|
|||||||
[updateProfileHistory, queryClient],
|
[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(() => {
|
const onSoftReset = useCallback(() => {
|
||||||
if (IS_WEB) {
|
if (IS_WEB) {
|
||||||
/*
|
/*
|
||||||
@@ -495,13 +535,11 @@ export function SearchScreenShell({
|
|||||||
{isExplore ? <Trans>Explore</Trans> : <Trans>Search</Trans>}
|
{isExplore ? <Trans>Explore</Trans> : <Trans>Search</Trans>}
|
||||||
</Layout.Header.TitleText>
|
</Layout.Header.TitleText>
|
||||||
</Layout.Header.Content>
|
</Layout.Header.Content>
|
||||||
{showFilters ? (
|
{showFilters && !advancedSearchV2Enabled ? (
|
||||||
advancedSearchV2Enabled ? null : (
|
<SearchLanguageDropdown
|
||||||
<SearchLanguageDropdown
|
value={filters.lang ?? ''}
|
||||||
value={filters.lang ?? ''}
|
onChange={onChangeLang}
|
||||||
onChange={onChangeLang}
|
/>
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<Layout.Header.Slot />
|
<Layout.Header.Slot />
|
||||||
)}
|
)}
|
||||||
@@ -528,7 +566,7 @@ export function SearchScreenShell({
|
|||||||
size="large"
|
size="large"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
shape="rectangular"
|
shape="round"
|
||||||
style={[a.px_sm]}
|
style={[a.px_sm]}
|
||||||
onPress={onPressCancelSearch}
|
onPress={onPressCancelSearch}
|
||||||
hitSlop={HITSLOP_10}>
|
hitSlop={HITSLOP_10}>
|
||||||
@@ -536,7 +574,7 @@ export function SearchScreenShell({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
<SearchInput
|
<SearchAutocompleteInput
|
||||||
testID="searchScreenInput"
|
testID="searchScreenInput"
|
||||||
ref={textInput}
|
ref={textInput}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
@@ -547,6 +585,9 @@ export function SearchScreenShell({
|
|||||||
placeholder={inputPlaceholder ?? l`Search`}
|
placeholder={inputPlaceholder ?? l`Search`}
|
||||||
hitSlop={{...HITSLOP_20, top: 0}}
|
hitSlop={{...HITSLOP_20, top: 0}}
|
||||||
hotkey={true}
|
hotkey={true}
|
||||||
|
fixedParams={Boolean(fixedParams)}
|
||||||
|
onSelectProfile={onSelectProfile}
|
||||||
|
onSelectSearch={onSelectSearch}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -589,10 +630,10 @@ export function SearchScreenShell({
|
|||||||
display: showAutocomplete && !fixedParams ? 'flex' : 'none',
|
display: showAutocomplete && !fixedParams ? 'flex' : 'none',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
}}>
|
}}>
|
||||||
{searchText.length > 0 ? (
|
{searchText.length > 0 && IS_NATIVE ? (
|
||||||
<AutocompleteResults
|
<AutocompleteResults
|
||||||
isAutocompleteFetching={isAutocompleteFetching}
|
items={autocompleteItems}
|
||||||
autocompleteData={autocompleteData}
|
isFetching={isAutocompleteFetching}
|
||||||
searchText={searchText}
|
searchText={searchText}
|
||||||
onSubmit={onSubmit('autocomplete')}
|
onSubmit={onSubmit('autocomplete')}
|
||||||
onResultPress={onAutocompleteResultPress}
|
onResultPress={onAutocompleteResultPress}
|
||||||
|
|||||||
@@ -1,49 +1,44 @@
|
|||||||
import {memo} from 'react'
|
import {memo} from 'react'
|
||||||
import {
|
import {TouchableOpacity, View, type ViewStyle} from 'react-native'
|
||||||
ActivityIndicator,
|
import {useLingui} from '@lingui/react/macro'
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
type ViewStyle,
|
|
||||||
} from 'react-native'
|
|
||||||
import {type AppBskyActorDefs} from '@atproto/api'
|
|
||||||
import {msg} from '@lingui/core/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {Link} from '#/view/com/util/Link'
|
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
|
||||||
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
|
import {SearchProfileCard} from '#/screens/Search/components/SearchProfileCard'
|
||||||
import {atoms as a, native} from '#/alf'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import {type AutocompleteItem} from '#/components/Autocomplete'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
import {useAnalytics} from '#/analytics'
|
import {useAnalytics} from '#/analytics'
|
||||||
import {IS_NATIVE} from '#/env'
|
import {IS_NATIVE} from '#/env'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
let AutocompleteResults = ({
|
let AutocompleteResults = ({
|
||||||
isAutocompleteFetching,
|
items,
|
||||||
autocompleteData,
|
isFetching,
|
||||||
searchText,
|
searchText,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onResultPress,
|
onResultPress,
|
||||||
onProfileClick,
|
onProfileClick,
|
||||||
}: {
|
}: {
|
||||||
isAutocompleteFetching: boolean
|
items: AutocompleteItem[]
|
||||||
autocompleteData: AppBskyActorDefs.ProfileViewBasic[] | undefined
|
isFetching: boolean
|
||||||
searchText: string
|
searchText: string
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
onResultPress: () => void
|
onResultPress: () => void
|
||||||
onProfileClick: (profile: AppBskyActorDefs.ProfileViewBasic) => void
|
onProfileClick: (profile: bsky.profile.AnyProfileView) => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(isAutocompleteFetching && !autocompleteData?.length) ||
|
{(isFetching && !items.length) || !moderationOpts ? (
|
||||||
!moderationOpts ? (
|
|
||||||
<Layout.Content>
|
<Layout.Content>
|
||||||
<View style={[a.py_xl]}>
|
<View style={[a.py_xl, a.align_center]}>
|
||||||
<ActivityIndicator />
|
<Loader size="xl" />
|
||||||
</View>
|
</View>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
) : (
|
) : (
|
||||||
@@ -51,7 +46,7 @@ let AutocompleteResults = ({
|
|||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
keyboardDismissMode="on-drag">
|
keyboardDismissMode="on-drag">
|
||||||
<SearchLinkCard
|
<SearchLinkCard
|
||||||
label={_(msg`Search for "${searchText}"`)}
|
label={l`Search for “${searchText}”`}
|
||||||
onPress={native(onSubmit)}
|
onPress={native(onSubmit)}
|
||||||
to={
|
to={
|
||||||
IS_NATIVE
|
IS_NATIVE
|
||||||
@@ -60,21 +55,24 @@ let AutocompleteResults = ({
|
|||||||
}
|
}
|
||||||
style={a.border_b}
|
style={a.border_b}
|
||||||
/>
|
/>
|
||||||
{autocompleteData?.map((item, index) => (
|
{items.map((item, index) => {
|
||||||
<SearchProfileCard
|
if (item.type !== 'profile') return null
|
||||||
key={item.did}
|
return (
|
||||||
profile={item}
|
<SearchProfileCard
|
||||||
moderationOpts={moderationOpts}
|
key={item.key}
|
||||||
onPress={() => {
|
profile={item.profile}
|
||||||
ax.metric('search:autocomplete:press', {
|
moderationOpts={moderationOpts}
|
||||||
profileDid: item.did,
|
onPress={() => {
|
||||||
position: index,
|
ax.metric('search:autocomplete:press', {
|
||||||
})
|
profileDid: item.profile.did,
|
||||||
onProfileClick(item)
|
position: index,
|
||||||
onResultPress()
|
})
|
||||||
}}
|
onProfileClick(item.profile)
|
||||||
/>
|
onResultPress()
|
||||||
))}
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<View style={{height: 200}} />
|
<View style={{height: 200}} />
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
)}
|
)}
|
||||||
@@ -95,12 +93,12 @@ let SearchLinkCard = ({
|
|||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
style?: ViewStyle
|
style?: ViewStyle
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<View
|
<View
|
||||||
style={[pal.border, {paddingVertical: 16, paddingHorizontal: 12}, style]}>
|
style={[a.flex_1, a.py_lg, a.px_md, t.atoms.border_contrast_low, style]}>
|
||||||
<Text type="md" style={[pal.text]}>
|
<Text emoji style={[a.text_md, t.atoms.text]}>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -117,18 +115,13 @@ let SearchLinkCard = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
if (to) {
|
||||||
<Link href={to} asAnchor anchorNoUnderline>
|
return (
|
||||||
<View
|
<Link label={label} to={to}>
|
||||||
style={[
|
{inner}
|
||||||
pal.border,
|
</Link>
|
||||||
{paddingVertical: 16, paddingHorizontal: 12},
|
)
|
||||||
style,
|
}
|
||||||
]}>
|
|
||||||
<Text type="md" style={[pal.text]}>
|
return inner
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <SearchInput {...rest} />
|
||||||
|
}
|
||||||
@@ -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<TextInput>(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 (
|
||||||
|
<View
|
||||||
|
collapsable={false}
|
||||||
|
ref={setAnchor}
|
||||||
|
/*
|
||||||
|
* Focusing the input reveals the Cancel button alongside it (see
|
||||||
|
* Shell.tsx), which shrinks the anchor after Sift's initial measurement
|
||||||
|
* and leaves the dropdown too wide until the next re-measure. Recompute on
|
||||||
|
* layout (react-native-web backs onLayout with a ResizeObserver) so the
|
||||||
|
* width tracks the anchor through that reflow and window resizes.
|
||||||
|
*/
|
||||||
|
onLayout={() => void sift.updatePosition()}>
|
||||||
|
<SearchInput
|
||||||
|
{...rest}
|
||||||
|
{...comboboxProps}
|
||||||
|
ref={mergeRefs([ref, inputAnchorRef, inputRef])}
|
||||||
|
value={value}
|
||||||
|
onChangeText={text => {
|
||||||
|
setDismissed(false)
|
||||||
|
onChangeText?.(text)
|
||||||
|
}}
|
||||||
|
onFocus={e => {
|
||||||
|
setDismissed(false)
|
||||||
|
setFocused(true)
|
||||||
|
onFocus?.(e)
|
||||||
|
}}
|
||||||
|
onBlur={e => {
|
||||||
|
setFocused(false)
|
||||||
|
onBlur?.(e)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showDropdown && (
|
||||||
|
<Autocomplete
|
||||||
|
sift={sift}
|
||||||
|
data={items}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onDismiss={() => setDismissed(true)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {type SearchInput} from '#/components/forms/SearchInput'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
|
type SearchInputProps = React.ComponentProps<typeof SearchInput>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {useCallback} from 'react'
|
import {useCallback} from 'react'
|
||||||
import {View} from 'react-native'
|
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 {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
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 {atoms as a, useTheme} from '#/alf'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
|
import type * as bsky from '#/types/bsky'
|
||||||
|
|
||||||
export function SearchProfileCard({
|
export function SearchProfileCard({
|
||||||
profile,
|
profile,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
onPress: onPressInner,
|
onPress: onPressInner,
|
||||||
}: {
|
}: {
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: bsky.profile.AnyProfileView
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
onPress?: () => void
|
onPress?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -82,7 +82,19 @@ export function NotificationSettingsScreen({}: Props) {
|
|||||||
const onRequestPermissions = async () => {
|
const onRequestPermissions = async () => {
|
||||||
if (IS_WEB) return
|
if (IS_WEB) return
|
||||||
if (permissions?.canAskAgain) {
|
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)
|
queryClient.setQueryData(RQKEY, response)
|
||||||
} else {
|
} else {
|
||||||
if (IS_ANDROID) {
|
if (IS_ANDROID) {
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ export function usePinnedFeedsInfos() {
|
|||||||
pinnedItems.map(f => f.value),
|
pinnedItems.map(f => f.value),
|
||||||
),
|
),
|
||||||
gcTime: GCTIME.INFINITY,
|
gcTime: GCTIME.INFINITY,
|
||||||
staleTime: STALE.INFINITY,
|
staleTime: STALE.MINUTES.FIFTEEN,
|
||||||
enabled: !isLoadingPrefs,
|
enabled: !isLoadingPrefs,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const STALE = {
|
|||||||
ONE: MINUTE,
|
ONE: MINUTE,
|
||||||
THREE: 3 * MINUTE,
|
THREE: 3 * MINUTE,
|
||||||
FIVE: 5 * MINUTE,
|
FIVE: 5 * MINUTE,
|
||||||
|
FIFTEEN: 15 * MINUTE,
|
||||||
THIRTY: 30 * MINUTE,
|
THIRTY: 30 * MINUTE,
|
||||||
},
|
},
|
||||||
HOURS: {
|
HOURS: {
|
||||||
|
|||||||
@@ -74,14 +74,6 @@ export function useSearchPostsV2Query({
|
|||||||
*/
|
*/
|
||||||
const {q, ...embedded} = extractSearchPostsParams(query)
|
const {q, ...embedded} = extractSearchPostsParams(query)
|
||||||
const builtFilters = buildSearchPostsV2Filters(embedded, filters)
|
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({
|
const res = await agent.app.bsky.feed.searchPostsV2({
|
||||||
...builtFilters,
|
...builtFilters,
|
||||||
query: q,
|
query: q,
|
||||||
@@ -92,7 +84,7 @@ export function useSearchPostsV2Query({
|
|||||||
* the v1 'latest' label.
|
* the v1 'latest' label.
|
||||||
*/
|
*/
|
||||||
sort: sort === 'latest' ? 'recent' : sort,
|
sort: sort === 'latest' ? 'recent' : sort,
|
||||||
allTime: sort !== 'latest' || hasDateFilter,
|
allTime: true,
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,29 +22,35 @@ import {
|
|||||||
} from './util'
|
} from './util'
|
||||||
|
|
||||||
const searchPostsQueryKeyRoot = 'search-posts'
|
const searchPostsQueryKeyRoot = 'search-posts'
|
||||||
const searchPostsQueryKey = ({query, sort}: {query: string; sort?: string}) => [
|
const searchPostsQueryKey = ({
|
||||||
searchPostsQueryKeyRoot,
|
|
||||||
query,
|
query,
|
||||||
sort,
|
sort,
|
||||||
]
|
author,
|
||||||
|
}: {
|
||||||
|
query: string
|
||||||
|
sort?: string
|
||||||
|
author?: string
|
||||||
|
}) => [searchPostsQueryKeyRoot, query, sort, author]
|
||||||
|
|
||||||
export function useSearchPostsQuery({
|
export function useSearchPostsQuery({
|
||||||
query,
|
query,
|
||||||
sort,
|
sort,
|
||||||
enabled,
|
enabled,
|
||||||
|
author,
|
||||||
}: {
|
}: {
|
||||||
query: string
|
query: string
|
||||||
sort?: 'top' | 'latest'
|
sort?: 'top' | 'latest'
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
author?: string
|
||||||
}) {
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const selectArgs = useMemo(
|
const selectArgs = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isSearchingSpecificUser: /from:(\w+)/.test(query),
|
isSearchingSpecificUser: !!author || /from:(\w+)/.test(query),
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
}),
|
}),
|
||||||
[query, moderationOpts],
|
[query, author, moderationOpts],
|
||||||
)
|
)
|
||||||
const lastRun = useRef<{
|
const lastRun = useRef<{
|
||||||
data: InfiniteData<AppBskyFeedSearchPosts.OutputSchema>
|
data: InfiniteData<AppBskyFeedSearchPosts.OutputSchema>
|
||||||
@@ -59,13 +65,14 @@ export function useSearchPostsQuery({
|
|||||||
QueryKey,
|
QueryKey,
|
||||||
string | undefined
|
string | undefined
|
||||||
>({
|
>({
|
||||||
queryKey: searchPostsQueryKey({query, sort}),
|
queryKey: searchPostsQueryKey({query, sort, author}),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const res = await agent.app.bsky.feed.searchPosts({
|
const res = await agent.app.bsky.feed.searchPosts({
|
||||||
q: query,
|
q: query,
|
||||||
limit: 25,
|
limit: 25,
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
sort,
|
sort,
|
||||||
|
author,
|
||||||
})
|
})
|
||||||
return res.data
|
return res.data
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<AppBskyFeedGetSuggestedFeeds.OutputSchema>,
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import {createContext, useContext} from 'react'
|
|
||||||
|
|
||||||
interface PostProgressState {
|
|
||||||
progress: number
|
|
||||||
status: 'pending' | 'success' | 'error' | 'idle'
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const PostProgressContext = createContext<PostProgressState>({
|
|
||||||
progress: 0,
|
|
||||||
status: 'idle',
|
|
||||||
})
|
|
||||||
PostProgressContext.displayName = 'PostProgressContext'
|
|
||||||
|
|
||||||
export function Provider() {}
|
|
||||||
|
|
||||||
export function usePostProgress() {
|
|
||||||
return useContext(PostProgressContext)
|
|
||||||
}
|
|
||||||
@@ -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<StateContext>(undefined)
|
|
||||||
stateContext.displayName = 'ActiveStarterPackStateContext'
|
|
||||||
const setContext = createContext<SetContext>((_: StateContext) => {})
|
|
||||||
setContext.displayName = 'ActiveStarterPackSetContext'
|
|
||||||
|
|
||||||
export function Provider({children}: {children: React.ReactNode}) {
|
|
||||||
const [state, setState] = useState<StateContext>()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<stateContext.Provider value={state}>
|
|
||||||
<setContext.Provider value={setState}>{children}</setContext.Provider>
|
|
||||||
</stateContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useActiveStarterPack = () => useContext(stateContext)
|
|
||||||
export const useSetActiveStarterPack = () => useContext(setContext)
|
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
BackHandler,
|
BackHandler,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
@@ -21,6 +20,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
|
||||||
// @ts-expect-error no type definition
|
// @ts-expect-error no type definition
|
||||||
import ProgressCircle from 'react-native-progress/Circle'
|
import ProgressCircle from 'react-native-progress/Circle'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -72,7 +72,6 @@ import {
|
|||||||
type SupportedMimeTypes,
|
type SupportedMimeTypes,
|
||||||
VIDEO_MAX_DURATION_MS,
|
VIDEO_MAX_DURATION_MS,
|
||||||
} from '#/lib/constants'
|
} from '#/lib/constants'
|
||||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
|
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
|
||||||
import {mimeToExt} from '#/lib/media/video/util'
|
import {mimeToExt} from '#/lib/media/video/util'
|
||||||
@@ -287,7 +286,6 @@ export const ComposePost = ({
|
|||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const navigation = useNavigation<NavigationProp>()
|
const navigation = useNavigation<NavigationProp>()
|
||||||
|
|
||||||
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
|
|
||||||
const [isPublishing, setIsPublishing] = useState(false)
|
const [isPublishing, setIsPublishing] = useState(false)
|
||||||
const [publishingStage, setPublishingStage] = useState('')
|
const [publishingStage, setPublishingStage] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -856,17 +854,9 @@ export const ComposePost = ({
|
|||||||
const viewStyles = useMemo(
|
const viewStyles = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
paddingTop: IS_ANDROID ? insets.top : 0,
|
paddingTop: IS_ANDROID ? insets.top : 0,
|
||||||
paddingBottom:
|
paddingBottom: insets.bottom,
|
||||||
// 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,
|
|
||||||
}),
|
}),
|
||||||
[insets, isKeyboardVisible],
|
[insets.top, insets.bottom],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressCancel = useCallback(() => {
|
const onPressCancel = useCallback(() => {
|
||||||
@@ -1100,13 +1090,13 @@ export const ComposePost = ({
|
|||||||
posts,
|
posts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (waitErr: any) {
|
} catch (waitErr) {
|
||||||
logger.info(`composer: waiting for app view failed`, {
|
logger.info(`composer: waiting for app view failed`, {
|
||||||
safeMessage: waitErr,
|
safeMessage: waitErr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
logger.error(e, {
|
logger.error(e instanceof Error ? e : String(e), {
|
||||||
message: `Composer: create post failed`,
|
message: `Composer: create post failed`,
|
||||||
hasImages: filteredThread.posts.some(
|
hasImages: filteredThread.posts.some(
|
||||||
p =>
|
p =>
|
||||||
@@ -1115,7 +1105,7 @@ export const ComposePost = ({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
let err = cleanError(e.message)
|
let err = e instanceof Error ? cleanError(e.message) : String(e)
|
||||||
if (
|
if (
|
||||||
e instanceof apilib.ReplyDeletedError ||
|
e instanceof apilib.ReplyDeletedError ||
|
||||||
err.includes('not locate record')
|
err.includes('not locate record')
|
||||||
@@ -1417,8 +1407,8 @@ export const ComposePost = ({
|
|||||||
publishingStage={publishingStage}
|
publishingStage={publishingStage}
|
||||||
topBarAnimatedStyle={topBarAnimatedStyle}
|
topBarAnimatedStyle={topBarAnimatedStyle}
|
||||||
onCancel={onPressCancel}
|
onCancel={onPressCancel}
|
||||||
onPublish={onPressPublish}
|
onPublish={() => void onPressPublish()}
|
||||||
onSelectDraft={handleSelectDraft}
|
onSelectDraft={draft => void handleSelectDraft(draft)}
|
||||||
onSaveDraft={saveCurrentDraft}
|
onSaveDraft={saveCurrentDraft}
|
||||||
onDiscard={handleClearComposer}
|
onDiscard={handleClearComposer}
|
||||||
isEmpty={isComposerEmpty}
|
isEmpty={isComposerEmpty}
|
||||||
@@ -1531,7 +1521,7 @@ export const ComposePost = ({
|
|||||||
{allPostsWithinLimit && (
|
{allPostsWithinLimit && (
|
||||||
<Prompt.Action
|
<Prompt.Action
|
||||||
cta={composerState.draftId ? l`Save changes` : l`Save draft`}
|
cta={composerState.draftId ? l`Save changes` : l`Save draft`}
|
||||||
onPress={handleSaveDraft}
|
onPress={() => void handleSaveDraft()}
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1694,7 +1684,7 @@ let ComposerPost = memo(function ComposerPost({
|
|||||||
postId: post.id,
|
postId: post.id,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
onPhotoPasted={onPhotoPasted}
|
onPhotoPasted={uri => void onPhotoPasted(uri)}
|
||||||
onNewLink={onNewLink}
|
onNewLink={onNewLink}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
onPressPublish={onPublish}
|
onPressPublish={onPublish}
|
||||||
@@ -2188,7 +2178,7 @@ function ComposerFooter({
|
|||||||
}),
|
}),
|
||||||
).catch(e => {
|
).catch(e => {
|
||||||
logger.error(`createComposerImage failed`, {
|
logger.error(`createComposerImage failed`, {
|
||||||
safeMessage: e.message,
|
safeMessage: e instanceof Error ? e.message : String(e),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2425,24 +2415,31 @@ function useScrollTracker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useKeyboardVerticalOffset() {
|
function useKeyboardVerticalOffset() {
|
||||||
const {top, bottom} = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
|
|
||||||
// Android etc
|
// the keyboardavoidingview has bottom padding to avoid being obscured by the safe area when keyboard is closed.
|
||||||
if (!IS_IOS) {
|
// however, this leads to a gap when the keyboard is open. we account for that by subtracting the bottom inset when open.
|
||||||
// need to account for the edge-to-edge nav bar
|
let keyboardVerticalOffset = insets.bottom * -1
|
||||||
return 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
|
return keyboardVerticalOffset
|
||||||
if (IS_LIQUID_GLASS) {
|
|
||||||
return top
|
|
||||||
}
|
|
||||||
|
|
||||||
// iPhone SE
|
|
||||||
if (top === 20) return 40
|
|
||||||
|
|
||||||
// all other iPhones on <26
|
|
||||||
return top + 10
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function whenAppViewReady(
|
async function whenAppViewReady(
|
||||||
|
|||||||
@@ -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 <View style={style}>{children}</View>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardStickyView offset={{closed: -bottom}} style={style}>
|
|
||||||
{children}
|
|
||||||
</KeyboardStickyView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -288,6 +288,7 @@ export async function processVideo(
|
|||||||
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
||||||
},
|
},
|
||||||
signal,
|
signal,
|
||||||
|
onProbe: metadata => telemetry.probed(metadata),
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = getCompressErrorMessage(e, i18n)
|
const message = getCompressErrorMessage(e, i18n)
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ export function ComposerPrompt() {
|
|||||||
}),
|
}),
|
||||||
web({
|
web({
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
outline: 'none',
|
|
||||||
}),
|
}),
|
||||||
pressed && web({outline: 'none'}),
|
pressed && web({outline: 'none'}),
|
||||||
]}>
|
]}>
|
||||||
|
|||||||
@@ -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<any>
|
|
||||||
| React.ReactElement<any>
|
|
||||||
| null
|
|
||||||
| undefined
|
|
||||||
onSelectView?: (viewIndex: number) => void
|
|
||||||
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => 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<number>(0)
|
|
||||||
const flatListRef = useRef<FlatList_INTERNAL>(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 <View />
|
|
||||||
} else if (item === SELECTOR_ITEM) {
|
|
||||||
return (
|
|
||||||
<Selector
|
|
||||||
items={sections}
|
|
||||||
selectedIndex={selectedIndex}
|
|
||||||
onSelect={onPressSelection}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return renderItem(item)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sections, selectedIndex, onPressSelection, renderHeader, renderItem],
|
|
||||||
)
|
|
||||||
|
|
||||||
const data = useMemo(() => [HEADER_ITEM, SELECTOR_ITEM, ...items], [items])
|
|
||||||
return (
|
|
||||||
<FlatList_INTERNAL
|
|
||||||
// @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn
|
|
||||||
ref={flatListRef}
|
|
||||||
data={data}
|
|
||||||
keyExtractor={keyExtractor}
|
|
||||||
renderItem={renderItemInternal}
|
|
||||||
ListFooterComponent={ListFooterComponent}
|
|
||||||
// NOTE sticky header disabled on android due to major performance issues -prf
|
|
||||||
stickyHeaderIndices={IS_ANDROID ? undefined : STICKY_HEADER_INDICES}
|
|
||||||
onScroll={onScroll}
|
|
||||||
onEndReached={onEndReached}
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={refreshing!}
|
|
||||||
onRefresh={onRefresh}
|
|
||||||
tintColor={pal.colors.text}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
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 (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
backgroundColor: pal.colors.background,
|
|
||||||
}}>
|
|
||||||
<ScrollView
|
|
||||||
testID="selector"
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator={false}>
|
|
||||||
<View style={[pal.view, styles.outer]}>
|
|
||||||
{items.map((item, i) => {
|
|
||||||
const selected = i === selectedIndex
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
testID={`selector-${i}`}
|
|
||||||
key={item}
|
|
||||||
onPress={() => onPressItem(i)}
|
|
||||||
accessibilityLabel={item}
|
|
||||||
accessibilityHint={`Selects ${item}`}
|
|
||||||
// TODO: Modify the component API such that lint fails
|
|
||||||
// at the invocation site as well
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.item,
|
|
||||||
selected && styles.itemSelected,
|
|
||||||
borderColor,
|
|
||||||
]}>
|
|
||||||
<Text
|
|
||||||
style={
|
|
||||||
selected
|
|
||||||
? [styles.labelSelected, pal.text]
|
|
||||||
: [styles.label, pal.textLight]
|
|
||||||
}>
|
|
||||||
{item}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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 (
|
|
||||||
<ThemeProvider theme={colorScheme}>
|
|
||||||
<Layout.Screen>
|
|
||||||
<DebugInner
|
|
||||||
colorScheme={colorScheme}
|
|
||||||
onToggleColorScheme={onToggleColorScheme}
|
|
||||||
/>
|
|
||||||
</Layout.Screen>
|
|
||||||
</ThemeProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DebugInner({}: {
|
|
||||||
colorScheme: 'light' | 'dark'
|
|
||||||
onToggleColorScheme: () => void
|
|
||||||
}) {
|
|
||||||
const [currentView, setCurrentView] = useState<number>(0)
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const {_} = useLingui()
|
|
||||||
|
|
||||||
const renderItem = (item: any) => {
|
|
||||||
return (
|
|
||||||
<View key={`view-${item.currentView}`}>
|
|
||||||
{item.currentView === 3 ? (
|
|
||||||
<NotifsView />
|
|
||||||
) : item.currentView === 2 ? (
|
|
||||||
<ErrorView />
|
|
||||||
) : item.currentView === 1 ? (
|
|
||||||
<ControlsView />
|
|
||||||
) : (
|
|
||||||
<BaseView />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = [{currentView}]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[s.hContentRegion, pal.view]}>
|
|
||||||
<ViewHeader title={_(msg`Debug panel`)} />
|
|
||||||
<ViewSelector
|
|
||||||
swipeEnabled
|
|
||||||
sections={MAIN_VIEWS}
|
|
||||||
items={items}
|
|
||||||
renderItem={renderItem}
|
|
||||||
onSelectView={setCurrentView}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Heading({label}: {label: string}) {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
return (
|
|
||||||
<View style={[s.pt10, s.pb5]}>
|
|
||||||
<Text type="title-lg" style={pal.text}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BaseView() {
|
|
||||||
return (
|
|
||||||
<View style={[s.pl10, s.pr10]}>
|
|
||||||
<Heading label="Typography" />
|
|
||||||
<TypographyView />
|
|
||||||
<Heading label="Palettes" />
|
|
||||||
<PaletteView palette="default" />
|
|
||||||
<PaletteView palette="primary" />
|
|
||||||
<PaletteView palette="secondary" />
|
|
||||||
<PaletteView palette="inverted" />
|
|
||||||
<PaletteView palette="error" />
|
|
||||||
<Heading label="Empty state" />
|
|
||||||
<EmptyStateView />
|
|
||||||
<Heading label="Loading placeholders" />
|
|
||||||
<LoadingPlaceholderView />
|
|
||||||
<View style={s.footerSpacer} />
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ControlsView() {
|
|
||||||
return (
|
|
||||||
<ScrollView style={[s.pl10, s.pr10]}>
|
|
||||||
<Heading label="Buttons" />
|
|
||||||
<ButtonsView />
|
|
||||||
<View style={s.footerSpacer} />
|
|
||||||
</ScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ErrorView() {
|
|
||||||
return (
|
|
||||||
<View style={s.p10}>
|
|
||||||
<View style={s.mb5}>
|
|
||||||
<ErrorScreen
|
|
||||||
title="Error screen"
|
|
||||||
message="A major error occurred that led the entire screen to fail"
|
|
||||||
details="Here are some details"
|
|
||||||
onPressTryAgain={() => {}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={s.mb5}>
|
|
||||||
<ErrorMessage message="This is an error that occurred while things were being done" />
|
|
||||||
</View>
|
|
||||||
<View style={s.mb5}>
|
|
||||||
<ErrorMessage
|
|
||||||
message="This is an error that occurred while things were being done"
|
|
||||||
numberOfLines={1}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={s.mb5}>
|
|
||||||
<ErrorMessage
|
|
||||||
message="This is an error that occurred while things were being done"
|
|
||||||
onPressTryAgain={() => {}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={s.mb5}>
|
|
||||||
<ErrorMessage
|
|
||||||
message="This is an error that occurred while things were being done"
|
|
||||||
onPressTryAgain={() => {}}
|
|
||||||
numberOfLines={1}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<View style={s.p10}>
|
|
||||||
<View style={{flexDirection: 'row'}}>
|
|
||||||
<Button onPress={triggerPush} label="Trigger Push" />
|
|
||||||
<Button onPress={triggerToast} label="Trigger Toast" />
|
|
||||||
<Button onPress={triggerToast2} label="Trigger Toast 2" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaletteView({palette}: {palette: PaletteColorName}) {
|
|
||||||
const defaultPal = usePalette('default')
|
|
||||||
const pal = usePalette(palette)
|
|
||||||
return (
|
|
||||||
<View style={[pal.view, pal.border, s.p10, s.mb5, {borderWidth: 1}]}>
|
|
||||||
<Text style={[pal.text]}>{palette} colors</Text>
|
|
||||||
<Text style={[pal.textLight]}>Light text</Text>
|
|
||||||
<Text style={[pal.link]}>Link text</Text>
|
|
||||||
{palette !== 'default' && (
|
|
||||||
<View style={[defaultPal.view]}>
|
|
||||||
<Text style={[pal.textInverted]}>Inverted text</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TypographyView() {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
return (
|
|
||||||
<View style={[pal.view]}>
|
|
||||||
<Text type="2xl-thin" style={[pal.text]}>
|
|
||||||
'2xl-thin' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="2xl" style={[pal.text]}>
|
|
||||||
'2xl' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="2xl-medium" style={[pal.text]}>
|
|
||||||
'2xl-medium' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="2xl-bold" style={[pal.text]}>
|
|
||||||
'2xl-bold' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="2xl-heavy" style={[pal.text]}>
|
|
||||||
'2xl-heavy' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xl-thin" style={[pal.text]}>
|
|
||||||
'xl-thin' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xl" style={[pal.text]}>
|
|
||||||
'xl' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xl-medium" style={[pal.text]}>
|
|
||||||
'xl-medium' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xl-bold" style={[pal.text]}>
|
|
||||||
'xl-bold' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xl-heavy" style={[pal.text]}>
|
|
||||||
'xl-heavy' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="lg-thin" style={[pal.text]}>
|
|
||||||
'lg-thin' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="lg" style={[pal.text]}>
|
|
||||||
'lg' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="lg-medium" style={[pal.text]}>
|
|
||||||
'lg-medium' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="lg-bold" style={[pal.text]}>
|
|
||||||
'lg-bold' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="lg-heavy" style={[pal.text]}>
|
|
||||||
'lg-heavy' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="md-thin" style={[pal.text]}>
|
|
||||||
'md-thin' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="md" style={[pal.text]}>
|
|
||||||
'md' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="md-medium" style={[pal.text]}>
|
|
||||||
'md-medium' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="md-bold" style={[pal.text]}>
|
|
||||||
'md-bold' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="md-heavy" style={[pal.text]}>
|
|
||||||
'md-heavy' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="sm-thin" style={[pal.text]}>
|
|
||||||
'sm-thin' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="sm" style={[pal.text]}>
|
|
||||||
'sm' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="sm-medium" style={[pal.text]}>
|
|
||||||
'sm-medium' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="sm-bold" style={[pal.text]}>
|
|
||||||
'sm-bold' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="sm-heavy" style={[pal.text]}>
|
|
||||||
'sm-heavy' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xs-thin" style={[pal.text]}>
|
|
||||||
'xs-thin' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xs" style={[pal.text]}>
|
|
||||||
'xs' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xs-medium" style={[pal.text]}>
|
|
||||||
'xs-medium' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xs-bold" style={[pal.text]}>
|
|
||||||
'xs-bold' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="xs-heavy" style={[pal.text]}>
|
|
||||||
'xs-heavy' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text type="title-2xl" style={[pal.text]}>
|
|
||||||
'title-2xl' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="title-xl" style={[pal.text]}>
|
|
||||||
'title-xl' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="title-lg" style={[pal.text]}>
|
|
||||||
'title-lg' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="title" style={[pal.text]}>
|
|
||||||
'title' lorem ipsum dolor
|
|
||||||
</Text>
|
|
||||||
<Text type="button" style={[pal.text]}>
|
|
||||||
Button
|
|
||||||
</Text>
|
|
||||||
<Text type="button-lg" style={[pal.text]}>
|
|
||||||
Button-lg
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyStateView() {
|
|
||||||
const {_} = useLingui()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={HashtagWideIcon}
|
|
||||||
iconSize="2xl"
|
|
||||||
message={_(msg`This is an empty state`)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingPlaceholderView() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<LoadingPlaceholder.PostLoadingPlaceholder />
|
|
||||||
<LoadingPlaceholder.NotificationLoadingPlaceholder />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ButtonsView() {
|
|
||||||
const defaultPal = usePalette('default')
|
|
||||||
const buttonStyles = {marginRight: 5}
|
|
||||||
return (
|
|
||||||
<View style={[defaultPal.view]}>
|
|
||||||
<View style={[{flexDirection: 'row'}, s.mb5]}>
|
|
||||||
<Button type="primary" label="Primary solid" style={buttonStyles} />
|
|
||||||
<Button type="secondary" label="Secondary solid" style={buttonStyles} />
|
|
||||||
</View>
|
|
||||||
<View style={[{flexDirection: 'row'}, s.mb5]}>
|
|
||||||
<Button type="default" label="Default solid" style={buttonStyles} />
|
|
||||||
<Button type="inverted" label="Inverted solid" style={buttonStyles} />
|
|
||||||
</View>
|
|
||||||
<View style={{flexDirection: 'row'}}>
|
|
||||||
<Button
|
|
||||||
type="primary-outline"
|
|
||||||
label="Primary outline"
|
|
||||||
style={buttonStyles}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="secondary-outline"
|
|
||||||
label="Secondary outline"
|
|
||||||
style={buttonStyles}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={{flexDirection: 'row'}}>
|
|
||||||
<Button
|
|
||||||
type="primary-light"
|
|
||||||
label="Primary light"
|
|
||||||
style={buttonStyles}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="secondary-light"
|
|
||||||
label="Secondary light"
|
|
||||||
style={buttonStyles}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={{flexDirection: 'row'}}>
|
|
||||||
<Button
|
|
||||||
type="default-light"
|
|
||||||
label="Default light"
|
|
||||||
style={buttonStyles}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user