Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f62ae6ded1 | |||
| 5668dd3258 | |||
| e2335da909 | |||
| 181c0c83d8 | |||
| 3d3d581a7e | |||
| 24038e6148 | |||
| 4fe62640f1 | |||
| 2efc047f64 | |||
| 80049bbe82 | |||
| e627d37f42 | |||
| 24fe45aaa1 | |||
| 0deb9511af | |||
| 43ebb80a5b | |||
| d91e206e21 | |||
| 493bd6dcf6 | |||
| 4f6ff6fc73 | |||
| 7ba2997af5 | |||
| 4b6fbcc050 | |||
| 55806f1087 | |||
| 0aaf11aa7b | |||
| 9cfa7d0ba8 | |||
| 9743149c26 | |||
| 82877a096d | |||
| 12dc38c5fc | |||
| 7d5de60401 | |||
| 8136258cd8 | |||
| afe5db8b57 | |||
| 893dd78a00 | |||
| b38013a12f | |||
| aa1395542f | |||
| caa02db87b | |||
| f390167f66 | |||
| 6275d2e0fd | |||
| 3e1e1ba00e | |||
| 699fdbac01 | |||
| a14a9be127 | |||
| 998ee78e3d | |||
| a355eedf85 | |||
| 89cffdf5d0 | |||
| e9524d8a83 | |||
| f50b70e2b4 | |||
| e80e2f66c3 | |||
| f02b9c323f | |||
| 2200cc95d8 | |||
| f45e58e057 | |||
| da87515d0a | |||
| 348e7fa379 | |||
| df4e888347 | |||
| b48ca13172 | |||
| 3485dd37c2 | |||
| c9ce214a66 | |||
| 9971e91f9c | |||
| 8cee40beff | |||
| 4ffc1263e7 | |||
| ad7d49ef8f |
@@ -79,13 +79,15 @@ jobs:
|
||||
echo "$json" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight-android' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p android
|
||||
--profile ${{ inputs.profile || 'testflight-android' }}
|
||||
--profile $PROFILE
|
||||
--local --output build.aab --non-interactive
|
||||
|
||||
- name: ✍️ Rename Testflight bundle
|
||||
@@ -194,4 +196,6 @@ jobs:
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.profile == 'testflight-android' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -91,13 +91,15 @@ jobs:
|
||||
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
|
||||
|
||||
- name: 🏗️ EAS Build
|
||||
env:
|
||||
PROFILE: ${{ inputs.profile || 'testflight' }}
|
||||
run: >
|
||||
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_RELEASE=${{ steps.env.outputs.EXPO_PUBLIC_RELEASE_VERSION }}
|
||||
SENTRY_DIST=${{ steps.env.outputs.EXPO_PUBLIC_BUNDLE_IDENTIFIER }}
|
||||
yarn use-build-number-with-bump
|
||||
eas build -p ios
|
||||
--profile ${{ inputs.profile || 'testflight' }}
|
||||
--profile $PROFILE
|
||||
--local --output build.tar.gz --non-interactive
|
||||
|
||||
- name: 📂 Extract build artifact
|
||||
@@ -147,5 +149,7 @@ jobs:
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.profile == 'testflight' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -39,10 +39,12 @@ jobs:
|
||||
|
||||
# Validate the version if one is supplied. This should generally happen if the update is for a production client
|
||||
- name: 🧐 Validate version
|
||||
env:
|
||||
RUNTIME_VERSION: ${{ inputs.runtimeVersion }}
|
||||
if: ${{ inputs.runtimeVersion }}
|
||||
run: |
|
||||
if [ -z "${{ inputs.runtimeVersion }}" ]; then
|
||||
[[ "${{ inputs.runtimeVersion }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
|
||||
if [ -z "$RUNTIME_VERSION" ]; then
|
||||
[[ "$RUNTIME_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && echo "Version is valid" || exit 1
|
||||
fi
|
||||
|
||||
- name: ⬇️ Checkout
|
||||
@@ -103,12 +105,15 @@ jobs:
|
||||
|
||||
# eas.json not used here, set EXPO_PUBLIC_ENV
|
||||
- name: Env
|
||||
env:
|
||||
CHANNEL: ${{ inputs.channel || 'testflight' }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
id: env
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: |
|
||||
export json='${{ secrets.GOOGLE_SERVICES_TOKEN }}'
|
||||
echo "${{ secrets.ENV_TOKEN }}" > .env
|
||||
echo "EXPO_PUBLIC_ENV=${{ inputs.channel || 'testflight' }}" >> .env
|
||||
echo "EXPO_PUBLIC_ENV=$CHANNEL" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> .env
|
||||
echo "EXPO_PUBLIC_RELEASE_VERSION=$(jq -r '.version' package.json)" >> $GITHUB_OUTPUT
|
||||
echo "EXPO_PUBLIC_BUNDLE_IDENTIFIER=$(git rev-parse HEAD)" >> .env
|
||||
@@ -145,7 +150,7 @@ jobs:
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ !steps.fingerprint.outputs.includes-changes }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
# GitHub actions are horrible so let's just copy paste this in
|
||||
buildIfNecessaryIOS:
|
||||
@@ -249,7 +254,9 @@ jobs:
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
if: ${{ inputs.channel == 'testflight' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
buildIfNecessaryAndroid:
|
||||
name: Build and Submit Android
|
||||
@@ -363,5 +370,7 @@ jobs:
|
||||
key: most-recent-testflight-commit
|
||||
|
||||
- name: ✏️ Write commit hash to cache
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
if: ${{ inputs.channel != 'testflight' && inputs.channel != 'production' }}
|
||||
run: echo ${{ github.sha }} > most-recent-testflight-commit.txt
|
||||
run: echo $GITHUB_SHA > most-recent-testflight-commit.txt
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
fi
|
||||
|
||||
|
||||
if [[ "${{ github.event.comment.body }}" == *"ota"* ]]; then
|
||||
if [[ "$COMMENT" == *"ota"* ]]; then
|
||||
has_ota=true
|
||||
else
|
||||
has_ota=false
|
||||
@@ -75,6 +75,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Get PR HEAD SHA
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
id: pr-info
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -82,7 +84,7 @@ jobs:
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: ${{ github.event.issue.number }}
|
||||
pull_number: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
|
||||
console.log(`PR HEAD SHA: ${pr.data.head.sha}`);
|
||||
@@ -184,13 +186,15 @@ jobs:
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
with:
|
||||
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
|
||||
number: ${{ github.event.issue.number }}
|
||||
message: |
|
||||
Your requested OTA deployment was successful! You may now apply it by either scanning the QR code or opening the deep link below in your browser:
|
||||
|
||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-${{ github.event.issue.number }}" width=300 height=300>
|
||||
<img src="https://bsky-qr.vercel.app?channel=pull-request-$ISSUE_NUMBER" width=300 height=300>
|
||||
|
||||
`bluesky://intent/apply-ota?channel=pull-request-${{ github.event.issue.number }}`
|
||||
---
|
||||
|
||||
@@ -35,20 +35,27 @@ jobs:
|
||||
cache: yarn
|
||||
|
||||
- name: Ensure tracking relevant branches and checkout base
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git checkout ${{ github.head_ref }}
|
||||
git checkout ${{ github.base_ref }}
|
||||
git checkout $HEAD_REF
|
||||
git checkout $BASE_REF
|
||||
|
||||
- name: Get the base commit
|
||||
id: base-commit
|
||||
run: echo base-commit=$(git log -n 1 ${{ github.base_ref }} --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: echo base-commit=$(git log -n 1 $BASE_REF --pretty=format:'%H') >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Merge PR commit
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
# Have to set a git config for the merge to work
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git merge --no-edit ${{ github.head_ref }}
|
||||
git merge --no-edit $HEAD_REF
|
||||
yarn install
|
||||
yarn intl:build
|
||||
|
||||
@@ -84,7 +91,7 @@ jobs:
|
||||
with:
|
||||
base_path: "stats-base.json"
|
||||
pr_path: "../stats-new.json"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png"
|
||||
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
|
||||
|
||||
- name: 💬 Drop a comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
|
||||
@@ -17,7 +17,9 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch base branch
|
||||
run: git fetch origin ${{ github.base_ref }} --depth=1
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git fetch origin $BASE_REF --depth=1
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v6
|
||||
@@ -25,7 +27,9 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Reset yarn.lock to base
|
||||
run: git show "origin/${{ github.base_ref }}:yarn.lock" > yarn.lock
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: git show "origin/$BASE_REF:yarn.lock" > yarn.lock
|
||||
|
||||
- name: Yarn install
|
||||
uses: Wandalen/wretry.action@master
|
||||
|
||||
@@ -124,3 +124,4 @@ bskyogcard/src/assets/fonts/noto-*
|
||||
bskyweb/static/media/*.webp
|
||||
bskyweb/static/media/*.jpg
|
||||
bskyweb/static/media/*.png
|
||||
bskyweb/static/media/*.svg
|
||||
|
||||
@@ -246,7 +246,7 @@ module.exports = function (_config) {
|
||||
compileSdkVersion: 35,
|
||||
targetSdkVersion: 35,
|
||||
buildToolsVersion: '35.0.0',
|
||||
buildReactNativeFromSource: IS_PRODUCTION,
|
||||
buildReactNativeFromSource: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -389,6 +389,13 @@ module.exports = function (_config) {
|
||||
],
|
||||
['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}],
|
||||
['expo-location'],
|
||||
[
|
||||
'expo-contacts',
|
||||
{
|
||||
contactsPermission:
|
||||
'I agree to allow Bluesky to use my contacts for friend discovery until I opt out.',
|
||||
},
|
||||
],
|
||||
].filter(Boolean),
|
||||
extra: {
|
||||
eas: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M2.972 12a9 9 0 0 1 9-9c1.726 0 3.165.423 4.448 1.21A10 10 0 0 1 18 5.428V4a1 1 0 1 1 2 0v4a1 1 0 0 1-1 1h-4a1 1 0 1 1 0-2h1.756a8.3 8.3 0 0 0-1.382-1.085C14.417 5.327 13.341 5 11.972 5a7 7 0 1 0 6.601 9.333A1 1 0 0 1 20.46 15a9 9 0 0 1-17.487-3Z"/></svg>
|
||||
|
After Width: | Height: | Size: 349 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M7 2a3 3 0 0 0-3 3v14.5A2.5 2.5 0 0 0 6.5 22H19a1 1 0 1 0 0-2H6.5a.5.5 0 0 1 0-1H19a1 1 0 0 0 1-1V4a2 2 0 0 0-2-2H7Zm5 5a1.75 1.75 0 1 0 0 3.5A1.75 1.75 0 0 0 12 7Zm0 4c-.894 0-1.57.188-2.068.512a2.07 2.07 0 0 0-.852 1.058c-.286.838.432 1.43 1.03 1.43h3.78c.598 0 1.316-.592 1.03-1.43a2.07 2.07 0 0 0-.852-1.058C13.57 11.189 12.894 11 12 11Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 484 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M7 4a1 1 0 0 0-1 1v12.05q.243-.05.5-.05H18V4H7Zm5 7c.894 0 1.57.188 2.068.513a2.1 2.1 0 0 1 .806.937l.046.12c.285.836-.43 1.43-1.03 1.43h-3.78c-.6 0-1.315-.594-1.03-1.43l.046-.12a2.1 2.1 0 0 1 .806-.937C10.43 11.188 11.106 11 12 11Zm0-4a1.75 1.75 0 1 1 0 3.5A1.75 1.75 0 0 1 12 7Zm8 11a1 1 0 0 1-1 1H6.5a.5.5 0 0 0 0 1H19a1 1 0 1 1 0 2H6.5A2.5 2.5 0 0 1 4 19.5V5a3 3 0 0 1 3-3h11a2 2 0 0 1 2 2v14Z"/></svg>
|
||||
|
After Width: | Height: | Size: 500 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M20 12a8 8 0 1 0-8 8c2.204 0 4.2-.89 5.648-2.333A7.97 7.97 0 0 0 20 12Zm-5.707-3.707a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 1 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 1 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 1 1 1.414-1.414L12 10.586l2.293-2.293ZM22 12a9.96 9.96 0 0 1-2.269 6.34l2.891 2.89a1 1 0 1 1-1.414 1.415l-2.893-2.893A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10Z"/></svg>
|
||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M55 32C55 19.298 44.703 9 32 9S9 19.298 9 32s10.298 23 23 23a22.93 22.93 0 0 0 16.235-6.708A22.93 22.93 0 0 0 55 32Zm-15.707-8.707a1 1 0 1 1 1.414 1.414L33.414 32l7.293 7.293a1 1 0 1 1-1.414 1.414L32 33.414l-7.293 7.293a1 1 0 1 1-1.414-1.414L30.586 32l-7.293-7.293a1 1 0 1 1 1.414-1.414L32 30.586l7.293-7.293ZM57 32a24.9 24.9 0 0 1-6.66 16.985l8.808 8.808a1 1 0 0 1-1.414 1.414l-8.81-8.81A24.9 24.9 0 0 1 32 57C18.193 57 7 45.807 7 32S18.193 7 32 7s25 11.193 25 25Z"/></svg>
|
||||
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 64 64"><path fill="#000" d="M24.952 33.435c10.897 0 19.195 6.496 22.58 15.65.784 2.117.258 4.183-1 5.683-1.242 1.48-3.194 2.414-5.33 2.415h-32.5c-2.136 0-4.089-.935-5.331-2.415-1.258-1.5-1.783-3.566-1-5.682 3.386-9.155 11.683-15.651 22.58-15.651Zm0 2.298c-9.885 0-17.355 5.849-20.425 14.15-.47 1.271-.174 2.48.604 3.407.795.947 2.096 1.594 3.57 1.594h32.5c1.476 0 2.777-.647 3.571-1.594.778-.928 1.074-2.136.605-3.406-3.07-8.302-10.54-14.15-20.425-14.15Zm33.262-14.59a1.15 1.15 0 1 1 1.625 1.626l-5.688 5.687 5.686 5.688a1.15 1.15 0 1 1-1.625 1.625l-5.686-5.688-5.687 5.688a1.15 1.15 0 1 1-1.625-1.625l5.687-5.688-5.689-5.687a1.15 1.15 0 1 1 1.625-1.625l5.689 5.687 5.688-5.687ZM24.949 5.552c6.557 0 11.874 5.316 11.874 11.874 0 6.557-5.317 11.874-11.874 11.874s-11.874-5.317-11.874-11.874S18.39 5.55 24.949 5.55Zm0 2.299a9.575 9.575 0 0 0-9.575 9.575A9.575 9.575 0 0 0 24.949 27a9.575 9.575 0 0 0 9.575-9.575A9.575 9.575 0 0 0 24.95 7.85Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1014 B |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -308,7 +308,6 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/settings/notifications/reposts-on-reposts", server.WebGeneric)
|
||||
e.GET("/settings/notifications/activity", server.WebGeneric)
|
||||
e.GET("/settings/notifications/miscellaneous", server.WebGeneric)
|
||||
e.GET("/settings/app-icon", server.WebGeneric)
|
||||
e.GET("/sys/debug", server.WebGeneric)
|
||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||
e.GET("/sys/log", server.WebGeneric)
|
||||
|
||||
@@ -108,9 +108,9 @@
|
||||
{% else %}
|
||||
<meta property="og:title" content="@{{ profileHandle }}">
|
||||
{% endif -%}
|
||||
<meta name="description" content="This post requires authentication to view.">
|
||||
<meta property="og:description" content="This post requires authentication to view.">
|
||||
<meta property="twitter:description" content="This post requires authentication to view.">
|
||||
<meta name="description" content="This author has chosen to make their posts visible only to people who are signed in.">
|
||||
<meta property="og:description" content="This author has chosen to make their posts visible only to people who are signed in.">
|
||||
<meta property="twitter:description" content="This author has chosen to make their posts visible only to people who are signed in.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
@@ -129,7 +129,7 @@
|
||||
<div id="bsky_post_summary">
|
||||
<h3>Post</h3>
|
||||
<p id="bsky_handle">{{ profileHandle }}</p>
|
||||
<p id="bsky_post_text">This post requires authentication to view.</p>
|
||||
<p id="bsky_post_text">This author has chosen to make their posts visible only to people who are signed in.</p>
|
||||
</div>
|
||||
{% endif -%}
|
||||
{%- endblock %}
|
||||
|
||||
@@ -111,6 +111,8 @@ This is NOT required for app development but if you also want to develop the Blu
|
||||
- Start the docker daemon (on MacOS this entails starting the Docker Desktop app)
|
||||
- Launch a Postgres database on port 5432
|
||||
- `cd packages/dev-env && pnpm start`
|
||||
|
||||
Run the account with the AppView proxy DID passed in as an environment variable: `EXPO_PUBLIC_BLUESKY_PROXY_DID=did:plc:dw4kbjf5mn7nhenabiqpkyh3 yarn start`
|
||||
|
||||
Then, when logging in or creating an account, point it to the localhost port of the devserver.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.112.0",
|
||||
"version": "1.113.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -62,6 +62,7 @@
|
||||
"intl:compile-if-needed": "is-ci || [ -f src/locale/locales/en/messages.js ] || yarn intl:compile",
|
||||
"intl:pull": "crowdin download translations --verbose -b main",
|
||||
"intl:push": "crowdin push translations --verbose -b main",
|
||||
"intl:push-sources": "crowdin push sources --verbose -b main",
|
||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
|
||||
"update-extensions": "bash scripts/updateExtensions.sh",
|
||||
"export": "npx expo export --dump-sourcemap && yarn upload-native-sourcemaps",
|
||||
@@ -72,10 +73,11 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.18.6",
|
||||
"@atproto/api": "^0.18.8",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@bsky.app/alf": "^0.1.5",
|
||||
"@bsky.app/alf": "^0.1.6",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.0",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -94,14 +96,12 @@
|
||||
"@haileyok/bluesky-video": "0.3.2",
|
||||
"@ipld/dag-cbor": "^9.2.0",
|
||||
"@lingui/react": "^4.14.1",
|
||||
"@mattermost/react-native-paste-input": "mattermost/react-native-paste-input",
|
||||
"@miblanchard/react-native-slider": "^2.6.0",
|
||||
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.3.13",
|
||||
"@react-navigation/drawer": "^7.3.12",
|
||||
"@react-navigation/native": "^7.1.9",
|
||||
"@react-navigation/native-stack": "^7.3.13",
|
||||
"@react-navigation/bottom-tabs": "^7.9.0",
|
||||
"@react-navigation/native": "^7.1.26",
|
||||
"@react-navigation/native-stack": "^7.9.0",
|
||||
"@sentry/react-native": "~6.20.0",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
"@tanstack/react-query": "5.25.0",
|
||||
@@ -132,46 +132,48 @@
|
||||
"emoji-mart": "^5.5.2",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "^54.0.22",
|
||||
"expo-application": "~7.0.7",
|
||||
"expo-blur": "~15.0.7",
|
||||
"expo-build-properties": "~1.0.9",
|
||||
"expo-camera": "~17.0.9",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-dev-client": "~6.0.16",
|
||||
"expo-device": "~8.0.9",
|
||||
"expo-file-system": "~19.0.17",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-image-crop-tool": "^0.4.0",
|
||||
"expo-image-manipulator": "~14.0.7",
|
||||
"expo-image-picker": "~17.0.8",
|
||||
"expo-intent-launcher": "~13.0.7",
|
||||
"expo-keep-awake": "^15.0.7",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-location": "~19.0.7",
|
||||
"expo-media-library": "~18.2.0",
|
||||
"expo-notifications": "~0.32.12",
|
||||
"expo-screen-orientation": "~9.0.7",
|
||||
"expo-sharing": "~14.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-task-manager": "~14.0.8",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-video": "~3.0.13",
|
||||
"expo-web-browser": "~15.0.9",
|
||||
"expo": "^54.0.27",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-blur": "~15.0.8",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-clipboard": "~8.0.8",
|
||||
"expo-contacts": "^15.0.10",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.20",
|
||||
"expo-font": "~14.0.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
"expo-image-picker": "~17.0.9",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linear-gradient": "~15.0.8",
|
||||
"expo-linking": "~8.0.10",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-location": "~19.0.8",
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-notifications": "~0.32.14",
|
||||
"expo-screen-orientation": "~9.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-sms": "^14.0.7",
|
||||
"expo-splash-screen": "~31.0.12",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"expo-updates": "~29.0.14",
|
||||
"expo-video": "~3.0.15",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
"hls.js": "^1.6.2",
|
||||
"js-sha256": "^0.9.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lande": "^1.0.10",
|
||||
"libphonenumber-js": "^1.12.31",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"multiformats": "9.9.0",
|
||||
@@ -191,7 +193,7 @@
|
||||
"react-native-compressor": "^1.13.0",
|
||||
"react-native-date-picker": "^5.0.13",
|
||||
"react-native-device-attest": "^0.1.6",
|
||||
"react-native-drawer-layout": "^4.1.8",
|
||||
"react-native-drawer-layout": "^4.2.1",
|
||||
"react-native-edge-to-edge": "^1.6.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
@@ -201,7 +203,7 @@
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "^3.19.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-screens": "^4.19.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
@@ -219,10 +221,11 @@
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"tldts": "^6.1.46",
|
||||
"unicode-segmenter": "^0.14.5",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@atproto/dev-env": "^0.3.193",
|
||||
"@atproto/dev-env": "^0.3.196",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.26.0",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
@@ -239,7 +242,6 @@
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/lodash.chunk": "^4.2.7",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/lodash.isequal": "^4.5.6",
|
||||
"@types/lodash.shuffle": "^4.2.7",
|
||||
"@types/psl": "^1.1.1",
|
||||
"@types/react": "^19.1.12",
|
||||
@@ -264,11 +266,10 @@
|
||||
"husky": "^8.0.3",
|
||||
"is-ci": "^3.0.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "^54.0.14",
|
||||
"jest-expo": "~54.0.14",
|
||||
"jest-junit": "^16.0.0",
|
||||
"lint-staged": "^13.2.3",
|
||||
"lockfile-lint": "^4.14.0",
|
||||
"metro-react-native-babel-preset": "^0.77.0",
|
||||
"prettier": "^3.6.0",
|
||||
"react-native-dotenv": "^3.4.11",
|
||||
"react-refresh": "^0.14.0",
|
||||
@@ -285,7 +286,8 @@
|
||||
"**/expo-constants": "18.0.8",
|
||||
"**/expo-device": "7.1.4",
|
||||
"**/zod": "3.23.8",
|
||||
"**/multiformats": "9.9.0"
|
||||
"**/multiformats": "9.9.0",
|
||||
"unicode-segmenter": "0.14.5"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo/ios",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
diff --git a/node_modules/@haileyok/bluesky-video/android/build.gradle b/node_modules/@haileyok/bluesky-video/android/build.gradle
|
||||
index b988d3f..7743421 100644
|
||||
--- a/node_modules/@haileyok/bluesky-video/android/build.gradle
|
||||
+++ b/node_modules/@haileyok/bluesky-video/android/build.gradle
|
||||
@@ -36,6 +36,7 @@ android {
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
+ consumerProguardFiles 'proguard-rules.pro'
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
diff --git a/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
|
||||
new file mode 100644
|
||||
index 0000000..3b5b864
|
||||
--- /dev/null
|
||||
+++ b/node_modules/@haileyok/bluesky-video/android/proguard-rules.pro
|
||||
@@ -0,0 +1,2 @@
|
||||
+# Keep FullscreenActivity from being stripped by R8/ProGuard
|
||||
+-keep class expo.modules.blueskyvideo.FullscreenActivity { *; }
|
||||
@@ -1,13 +0,0 @@
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt b/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt
|
||||
index 4ed2307..ede1181 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/android/src/main/java/com/mattermost/pasteinputtext/PasteTextInputManager.kt
|
||||
@@ -54,7 +54,7 @@ class PasteTextInputManager(context: ReactApplicationContext) : ReactTextInputMa
|
||||
}
|
||||
|
||||
override fun getExportedCustomBubblingEventTypeConstants(): MutableMap<String, Any> {
|
||||
- val map = super.getExportedCustomBubblingEventTypeConstants()!!
|
||||
+ val map = super.getExportedCustomBubblingEventTypeConstants().toMutableMap()
|
||||
map["onPaste"] = MapBuilder.of(
|
||||
"phasedRegistrationNames",
|
||||
MapBuilder.of("bubbled", "onPaste")
|
||||
@@ -1,264 +0,0 @@
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
|
||||
index e916023..5049c33 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteInputView.m
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// Created by Elias Nahum on 04-11-20.
|
||||
// Copyright © 2020 Facebook. All rights reserved.
|
||||
+// Updated to remove parent’s default text view
|
||||
//
|
||||
|
||||
#import "PasteInputView.h"
|
||||
@@ -12,49 +13,78 @@
|
||||
|
||||
@implementation PasteInputView
|
||||
{
|
||||
- PasteInputTextView *_backedTextInputView;
|
||||
+ // We'll store the custom text view in this ivar
|
||||
+ PasteInputTextView *_customBackedTextView;
|
||||
}
|
||||
|
||||
- (instancetype)initWithBridge:(RCTBridge *)bridge
|
||||
{
|
||||
+ // Must call the super’s designated initializer
|
||||
if (self = [super initWithBridge:bridge]) {
|
||||
- _backedTextInputView = [[PasteInputTextView alloc] initWithFrame:self.bounds];
|
||||
- _backedTextInputView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
- _backedTextInputView.textInputDelegate = self;
|
||||
+ // 1. The parent (RCTMultilineTextInputView) has already created
|
||||
+ // its own _backedTextInputView = [RCTUITextView new] in super init.
|
||||
+ // We can remove that subview:
|
||||
|
||||
- [self addSubview:_backedTextInputView];
|
||||
- }
|
||||
+ id<RCTBackedTextInputViewProtocol> parentInputView = super.backedTextInputView;
|
||||
+ if ([parentInputView isKindOfClass:[UIView class]]) {
|
||||
+ UIView *parentSubview = (UIView *)parentInputView;
|
||||
+ if (parentSubview.superview == self) {
|
||||
+ [parentSubview removeFromSuperview];
|
||||
+ }
|
||||
+ }
|
||||
|
||||
+ // 2. Now create our custom PasteInputTextView
|
||||
+ _customBackedTextView = [[PasteInputTextView alloc] initWithFrame:self.bounds];
|
||||
+ _customBackedTextView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
+ _customBackedTextView.textInputDelegate = self;
|
||||
+
|
||||
+ // Optional: disable inline predictions for iOS 17+
|
||||
+ if (@available(iOS 17.0, *)) {
|
||||
+ _customBackedTextView.inlinePredictionType = UITextInlinePredictionTypeNo;
|
||||
+ }
|
||||
+
|
||||
+ // 3. Add your custom text view as the only subview
|
||||
+ [self addSubview:_customBackedTextView];
|
||||
+ }
|
||||
return self;
|
||||
}
|
||||
|
||||
+/**
|
||||
+ * Override the parent's accessor so that anywhere in RN that calls
|
||||
+ * `self.backedTextInputView` will get the custom PasteInputTextView.
|
||||
+ */
|
||||
- (id<RCTBackedTextInputViewProtocol>)backedTextInputView
|
||||
{
|
||||
- return _backedTextInputView;
|
||||
+ return _customBackedTextView;
|
||||
}
|
||||
|
||||
-- (void)setDisableCopyPaste:(BOOL)disableCopyPaste {
|
||||
- _backedTextInputView.disableCopyPaste = disableCopyPaste;
|
||||
+#pragma mark - Setters for React Props
|
||||
+
|
||||
+- (void)setDisableCopyPaste:(BOOL)disableCopyPaste
|
||||
+{
|
||||
+ _customBackedTextView.disableCopyPaste = disableCopyPaste;
|
||||
}
|
||||
|
||||
-- (void)setOnPaste:(RCTDirectEventBlock)onPaste {
|
||||
- _backedTextInputView.onPaste = onPaste;
|
||||
+- (void)setOnPaste:(RCTDirectEventBlock)onPaste
|
||||
+{
|
||||
+ _customBackedTextView.onPaste = onPaste;
|
||||
}
|
||||
|
||||
-- (void)setSmartPunctuation:(NSString *)smartPunctuation {
|
||||
- if ([smartPunctuation isEqualToString:@"enable"]) {
|
||||
- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeYes];
|
||||
- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeYes];
|
||||
- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeYes];
|
||||
- } else if ([smartPunctuation isEqualToString:@"disable"]) {
|
||||
- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeNo];
|
||||
- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeNo];
|
||||
- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeNo];
|
||||
- } else {
|
||||
- [_backedTextInputView setSmartDashesType:UITextSmartDashesTypeDefault];
|
||||
- [_backedTextInputView setSmartQuotesType:UITextSmartQuotesTypeDefault];
|
||||
- [_backedTextInputView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeDefault];
|
||||
- }
|
||||
+- (void)setSmartPunctuation:(NSString *)smartPunctuation
|
||||
+{
|
||||
+ if ([smartPunctuation isEqualToString:@"enable"]) {
|
||||
+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeYes];
|
||||
+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeYes];
|
||||
+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeYes];
|
||||
+ } else if ([smartPunctuation isEqualToString:@"disable"]) {
|
||||
+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeNo];
|
||||
+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeNo];
|
||||
+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeNo];
|
||||
+ } else {
|
||||
+ [_customBackedTextView setSmartDashesType:UITextSmartDashesTypeDefault];
|
||||
+ [_customBackedTextView setSmartQuotesType:UITextSmartQuotesTypeDefault];
|
||||
+ [_customBackedTextView setSmartInsertDeleteType:UITextSmartInsertDeleteTypeDefault];
|
||||
+ }
|
||||
}
|
||||
|
||||
#pragma mark - UIScrollViewDelegate
|
||||
@@ -62,7 +92,6 @@ - (void)setSmartPunctuation:(NSString *)smartPunctuation {
|
||||
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
|
||||
{
|
||||
RCTDirectEventBlock onScroll = self.onScroll;
|
||||
-
|
||||
if (onScroll) {
|
||||
CGPoint contentOffset = scrollView.contentOffset;
|
||||
CGSize contentSize = scrollView.contentSize;
|
||||
@@ -71,22 +100,22 @@ - (void)scrollViewDidScroll:(UIScrollView *)scrollView
|
||||
|
||||
onScroll(@{
|
||||
@"contentOffset": @{
|
||||
- @"x": @(contentOffset.x),
|
||||
- @"y": @(contentOffset.y)
|
||||
+ @"x": @(contentOffset.x),
|
||||
+ @"y": @(contentOffset.y)
|
||||
},
|
||||
@"contentInset": @{
|
||||
- @"top": @(contentInset.top),
|
||||
- @"left": @(contentInset.left),
|
||||
- @"bottom": @(contentInset.bottom),
|
||||
- @"right": @(contentInset.right)
|
||||
+ @"top": @(contentInset.top),
|
||||
+ @"left": @(contentInset.left),
|
||||
+ @"bottom": @(contentInset.bottom),
|
||||
+ @"right": @(contentInset.right)
|
||||
},
|
||||
@"contentSize": @{
|
||||
- @"width": @(contentSize.width),
|
||||
- @"height": @(contentSize.height)
|
||||
+ @"width": @(contentSize.width),
|
||||
+ @"height": @(contentSize.height)
|
||||
},
|
||||
@"layoutMeasurement": @{
|
||||
- @"width": @(size.width),
|
||||
- @"height": @(size.height)
|
||||
+ @"width": @(size.width),
|
||||
+ @"height": @(size.height)
|
||||
},
|
||||
@"zoomScale": @(scrollView.zoomScale ?: 1),
|
||||
});
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
|
||||
index dd50053..2ed7017 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInput.mm
|
||||
@@ -122,8 +122,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
|
||||
const auto &newTextInputProps = static_cast<const PasteTextInputProps &>(*props);
|
||||
|
||||
// Traits:
|
||||
- if (newTextInputProps.traits.multiline != oldTextInputProps.traits.multiline) {
|
||||
- [self _setMultiline:newTextInputProps.traits.multiline];
|
||||
+ if (newTextInputProps.multiline != oldTextInputProps.multiline) {
|
||||
+ [self _setMultiline:newTextInputProps.multiline];
|
||||
}
|
||||
|
||||
if (newTextInputProps.traits.autocapitalizationType != oldTextInputProps.traits.autocapitalizationType) {
|
||||
@@ -421,7 +421,7 @@ - (void)textInputDidChangeSelection
|
||||
return;
|
||||
}
|
||||
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
|
||||
- if (props.traits.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
|
||||
+ if (props.multiline && ![_lastStringStateWasUpdatedWith isEqual:_backedTextInputView.attributedText]) {
|
||||
[self textInputDidChange];
|
||||
_ignoreNextTextInputCall = YES;
|
||||
}
|
||||
@@ -708,11 +708,11 @@ - (BOOL)_textOf:(NSAttributedString *)newText equals:(NSAttributedString *)oldTe
|
||||
- (SubmitBehavior)getSubmitBehavior
|
||||
{
|
||||
const auto &props = static_cast<const PasteTextInputProps &>(*_props);
|
||||
- const SubmitBehavior submitBehaviorDefaultable = props.traits.submitBehavior;
|
||||
+ const SubmitBehavior submitBehaviorDefaultable = props.submitBehavior;
|
||||
|
||||
// We should always have a non-default `submitBehavior`, but in case we don't, set it based on multiline.
|
||||
if (submitBehaviorDefaultable == SubmitBehavior::Default) {
|
||||
- return props.traits.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
|
||||
+ return props.multiline ? SubmitBehavior::Newline : SubmitBehavior::BlurAndSubmit;
|
||||
}
|
||||
|
||||
return submitBehaviorDefaultable;
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
|
||||
index 29e094f..7ef519a 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.cpp
|
||||
@@ -22,8 +22,7 @@ PasteTextInputProps::PasteTextInputProps(
|
||||
const PropsParserContext &context,
|
||||
const PasteTextInputProps &sourceProps,
|
||||
const RawProps& rawProps)
|
||||
- : ViewProps(context, sourceProps, rawProps),
|
||||
- BaseTextProps(context, sourceProps, rawProps),
|
||||
+ : BaseTextInputProps(context, sourceProps, rawProps),
|
||||
traits(convertRawProp(context, rawProps, sourceProps.traits, {})),
|
||||
smartPunctuation(convertRawProp(context, rawProps, "smartPunctuation", sourceProps.smartPunctuation, {})),
|
||||
disableCopyPaste(convertRawProp(context, rawProps, "disableCopyPaste", sourceProps.disableCopyPaste, {false})),
|
||||
@@ -133,7 +132,7 @@ TextAttributes PasteTextInputProps::getEffectiveTextAttributes(Float fontSizeMul
|
||||
ParagraphAttributes PasteTextInputProps::getEffectiveParagraphAttributes() const {
|
||||
auto result = paragraphAttributes;
|
||||
|
||||
- if (!traits.multiline) {
|
||||
+ if (!multiline) {
|
||||
result.maximumNumberOfLines = 1;
|
||||
}
|
||||
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
|
||||
index 723d00c..31cfe66 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/Props.h
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <react/renderer/components/iostextinput/conversions.h>
|
||||
#include <react/renderer/components/iostextinput/primitives.h>
|
||||
#include <react/renderer/components/text/BaseTextProps.h>
|
||||
+#include <react/renderer/components/textinput/BaseTextInputProps.h>
|
||||
#include <react/renderer/components/view/ViewProps.h>
|
||||
#include <react/renderer/core/Props.h>
|
||||
#include <react/renderer/core/PropsParserContext.h>
|
||||
@@ -25,7 +26,7 @@
|
||||
|
||||
namespace facebook::react {
|
||||
|
||||
-class PasteTextInputProps final : public ViewProps, public BaseTextProps {
|
||||
+class PasteTextInputProps final : public BaseTextInputProps {
|
||||
public:
|
||||
PasteTextInputProps() = default;
|
||||
PasteTextInputProps(const PropsParserContext& context, const PasteTextInputProps& sourceProps, const RawProps& rawProps);
|
||||
diff --git a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
|
||||
index 31e07e3..7f0ebfb 100644
|
||||
--- a/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
|
||||
+++ b/node_modules/@mattermost/react-native-paste-input/ios/PasteTextInputSpecs/ShadowNodes.cpp
|
||||
@@ -91,20 +91,11 @@ void PasteTextInputShadowNode::updateStateIfNeeded(
|
||||
const auto& state = getStateData();
|
||||
|
||||
react_native_assert(textLayoutManager_);
|
||||
- react_native_assert(
|
||||
- (!state.layoutManager || state.layoutManager == textLayoutManager_) &&
|
||||
- "`StateData` refers to a different `TextLayoutManager`");
|
||||
-
|
||||
- if (state.reactTreeAttributedString == reactTreeAttributedString &&
|
||||
- state.layoutManager == textLayoutManager_) {
|
||||
- return;
|
||||
- }
|
||||
|
||||
auto newState = TextInputState{};
|
||||
newState.attributedStringBox = AttributedStringBox{reactTreeAttributedString};
|
||||
newState.paragraphAttributes = getConcreteProps().paragraphAttributes;
|
||||
newState.reactTreeAttributedString = reactTreeAttributedString;
|
||||
- newState.layoutManager = textLayoutManager_;
|
||||
newState.mostRecentEventCount = getConcreteProps().mostRecentEventCount;
|
||||
setStateData(std::move(newState));
|
||||
}
|
||||
@@ -1,24 +1,3 @@
|
||||
diff --git a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
|
||||
index e9a8e3d..3c684e0 100644
|
||||
--- a/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
|
||||
+++ b/node_modules/expo-updates/android/src/main/java/expo/modules/updates/loader/LoaderTask.kt
|
||||
@@ -296,14 +296,13 @@ class LoaderTask(
|
||||
) {
|
||||
try {
|
||||
val embeddedLoader = EmbeddedLoader(context, configuration, logger, database, directory)
|
||||
- val result = embeddedLoader.load { updateResponse ->
|
||||
+ embeddedLoader.load { _ ->
|
||||
Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true)
|
||||
}
|
||||
- launcher.launch(database)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Unexpected error copying embedded update", e, UpdatesErrorCode.Unknown)
|
||||
- launcher.launch(database)
|
||||
}
|
||||
+ launcher.launch(database)
|
||||
} else {
|
||||
launcher.launch(database)
|
||||
}
|
||||
diff --git a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift b/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||
index 68086bd..78c7761 100644
|
||||
--- a/node_modules/expo-updates/ios/EXUpdates/Update/ExpoUpdatesUpdate.swift
|
||||
@@ -0,0 +1,719 @@
|
||||
diff --git a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
|
||||
index 8cc1369..6b42a26 100644
|
||||
--- a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
|
||||
+++ b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js
|
||||
@@ -449,6 +449,21 @@ export type AndroidTextInputNativeProps = $ReadOnly<{
|
||||
}>,
|
||||
>,
|
||||
|
||||
+ /**
|
||||
+ * Invoked when the user performs the paste action.
|
||||
+ */
|
||||
+ onPaste?: ?DirectEventHandler<
|
||||
+ $ReadOnly<{
|
||||
+ target: Int32,
|
||||
+ items: $ReadOnlyArray<
|
||||
+ $ReadOnly<{
|
||||
+ type: string,
|
||||
+ data: string,
|
||||
+ }>,
|
||||
+ >,
|
||||
+ }>,
|
||||
+ >,
|
||||
+
|
||||
/**
|
||||
* The string that will be rendered before text input has been entered.
|
||||
*/
|
||||
@@ -640,6 +655,9 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
|
||||
topScroll: {
|
||||
registrationName: 'onScroll',
|
||||
},
|
||||
+ topPaste: {
|
||||
+ registrationName: 'onPaste',
|
||||
+ },
|
||||
},
|
||||
validAttributes: {
|
||||
acceptDragAndDropTypes: true,
|
||||
@@ -694,6 +712,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
|
||||
secureTextEntry: true,
|
||||
textBreakStrategy: true,
|
||||
onScroll: true,
|
||||
+ onPaste: true,
|
||||
onContentSizeChange: true,
|
||||
disableFullscreenUI: true,
|
||||
includeFontPadding: true,
|
||||
diff --git a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
|
||||
index 05bddcb..69c11a2 100644
|
||||
--- a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
|
||||
+++ b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js
|
||||
@@ -82,6 +82,9 @@ const RCTTextInputViewConfig: PartialViewConfigWithoutName = {
|
||||
topContentSizeChange: {
|
||||
registrationName: 'onContentSizeChange',
|
||||
},
|
||||
+ topPaste: {
|
||||
+ registrationName: 'onPaste',
|
||||
+ },
|
||||
topChangeSync: {
|
||||
registrationName: 'onChangeSync',
|
||||
},
|
||||
@@ -160,6 +163,7 @@ const RCTTextInputViewConfig: PartialViewConfigWithoutName = {
|
||||
onSelectionChange: true,
|
||||
onContentSizeChange: true,
|
||||
onScroll: true,
|
||||
+ onPaste: true,
|
||||
onChangeSync: true,
|
||||
onKeyPressSync: true,
|
||||
}),
|
||||
diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts
|
||||
index b3ca156..2807af2 100644
|
||||
--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts
|
||||
+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts
|
||||
@@ -565,6 +565,16 @@ export interface TextInputSubmitEditingEventData {
|
||||
export type TextInputSubmitEditingEvent =
|
||||
NativeSyntheticEvent<TextInputSubmitEditingEventData>;
|
||||
|
||||
+/**
|
||||
+ * @see TextInputProps.onPaste
|
||||
+ */
|
||||
+export interface TextInputPasteEventData extends TargetedEvent {
|
||||
+items: Array<{
|
||||
+ type: string;
|
||||
+ data: string;
|
||||
+ }>;
|
||||
+}
|
||||
+
|
||||
/**
|
||||
* @see https://reactnative.dev/docs/textinput#props
|
||||
*/
|
||||
@@ -896,6 +906,13 @@ export interface TextInputProps
|
||||
*/
|
||||
onKeyPress?: ((e: TextInputKeyPressEvent) => void) | undefined;
|
||||
|
||||
+ /**
|
||||
+ * Invoked when the user performs the paste action.
|
||||
+ */
|
||||
+ onPaste?:
|
||||
+ | ((e: NativeSyntheticEvent<TextInputPasteEventData>) => void)
|
||||
+ | undefined;
|
||||
+
|
||||
/**
|
||||
* The string that will be rendered before text input has been entered
|
||||
*/
|
||||
diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js
|
||||
index 5fa8811..ad2bf61 100644
|
||||
--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js
|
||||
+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js
|
||||
@@ -137,6 +137,18 @@ export type TextInputSubmitEditingEvent =
|
||||
export type TextInputEditingEvent =
|
||||
NativeSyntheticEvent<TextInputEndEditingEventData>;
|
||||
|
||||
+export type PasteEvent = SyntheticEvent<
|
||||
+ $ReadOnly<{
|
||||
+ target: number,
|
||||
+ items: $ReadOnlyArray<
|
||||
+ $ReadOnly<{
|
||||
+ type: string,
|
||||
+ data: string,
|
||||
+ }>,
|
||||
+ >,
|
||||
+ }>,
|
||||
+>;
|
||||
+
|
||||
type DataDetectorTypesType =
|
||||
| 'phoneNumber'
|
||||
| 'link'
|
||||
@@ -884,6 +896,11 @@ type TextInputBaseProps = $ReadOnly<{
|
||||
*/
|
||||
onScroll?: ?(e: ScrollEvent) => mixed,
|
||||
|
||||
+ /**
|
||||
+ * Invoked when the user performs the paste action.
|
||||
+ */
|
||||
+ onPaste?: ?(e: PasteEvent) => mixed,
|
||||
+
|
||||
/**
|
||||
* The string that will be rendered before text input has been entered.
|
||||
*/
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
|
||||
index 6e9c384..2c509eb 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
|
||||
@@ -13,6 +13,10 @@
|
||||
#import <React/RCTBackedTextInputDelegateAdapter.h>
|
||||
#import <React/RCTTextAttributes.h>
|
||||
|
||||
+#import <MobileCoreServices/MobileCoreServices.h>
|
||||
+#import <MobileCoreServices/UTType.h>
|
||||
+#import <UIKit/UIKit.h>
|
||||
+
|
||||
@implementation RCTUITextView {
|
||||
UILabel *_placeholderView;
|
||||
UITextView *_detachedTextView;
|
||||
@@ -209,7 +213,31 @@ - (void)scrollRangeToVisible:(NSRange)range
|
||||
- (void)paste:(id)sender
|
||||
{
|
||||
_textWasPasted = YES;
|
||||
- [super paste:sender];
|
||||
+ UIPasteboard *clipboard = [UIPasteboard generalPasteboard];
|
||||
+ if (clipboard.hasImages) {
|
||||
+ for (NSItemProvider *itemProvider in clipboard.itemProviders) {
|
||||
+ if ([itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) {
|
||||
+ for (NSString *identifier in itemProvider.registeredTypeIdentifiers) {
|
||||
+ if (UTTypeConformsTo((__bridge CFStringRef)identifier, kUTTypeImage)) {
|
||||
+ NSString *MIMEType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassMIMEType);
|
||||
+ NSString *fileExtension = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassFilenameExtension);
|
||||
+ NSString *filePath = RCTTempFilePath(fileExtension, nil);
|
||||
+ NSURL *fileURL = [NSURL fileURLWithPath:filePath];
|
||||
+ NSData *fileData = [clipboard dataForPasteboardType:identifier];
|
||||
+ [fileData writeToFile:filePath atomically:YES];
|
||||
+ [_textInputDelegateAdapter didPaste:MIMEType withData:[fileURL absoluteString]];
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ } else {
|
||||
+ if (clipboard.hasStrings) {
|
||||
+ [_textInputDelegateAdapter didPaste:@"text/plain" withData:clipboard.string];
|
||||
+ }
|
||||
+ [super paste:sender];
|
||||
+ }
|
||||
}
|
||||
|
||||
// Turn off scroll animation to fix flaky scrolling.
|
||||
@@ -301,6 +329,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender
|
||||
return NO;
|
||||
}
|
||||
|
||||
+ if (action == @selector(paste:) && [UIPasteboard generalPasteboard].hasImages) {
|
||||
+ return YES;
|
||||
+ }
|
||||
+
|
||||
return [super canPerformAction:action withSender:sender];
|
||||
}
|
||||
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegate.h b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegate.h
|
||||
index 7187177..da00893 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegate.h
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegate.h
|
||||
@@ -37,6 +37,8 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
- (void)textInputDidChangeSelection;
|
||||
|
||||
+- (void)textInputDidPaste:(NSString *)type withData:(NSString *)data;
|
||||
+
|
||||
@optional
|
||||
|
||||
- (void)scrollViewDidScroll:(UIScrollView *)scrollView;
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.h b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.h
|
||||
index f1c32e6..0ce9dfe 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.h
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.h
|
||||
@@ -20,6 +20,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
- (void)skipNextTextInputDidChangeSelectionEventWithTextRange:(UITextRange *)textRange;
|
||||
- (void)selectedTextRangeWasSet;
|
||||
+- (void)didPaste:(NSString *)type withData:(NSString *)data;
|
||||
|
||||
@end
|
||||
|
||||
@@ -30,6 +31,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
- (instancetype)initWithTextView:(UITextView<RCTBackedTextInputViewProtocol> *)backedTextInputView;
|
||||
|
||||
- (void)skipNextTextInputDidChangeSelectionEventWithTextRange:(UITextRange *)textRange;
|
||||
+- (void)didPaste:(NSString *)type withData:(NSString *)data;
|
||||
|
||||
@end
|
||||
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm
|
||||
index 82d9a79..8cc48ec 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm
|
||||
@@ -148,6 +148,11 @@ - (void)selectedTextRangeWasSet
|
||||
[self textFieldProbablyDidChangeSelection];
|
||||
}
|
||||
|
||||
+- (void)didPaste:(NSString *)type withData:(NSString *)data
|
||||
+{
|
||||
+ [_backedTextInputView.textInputDelegate textInputDidPaste:type withData:data];
|
||||
+}
|
||||
+
|
||||
#pragma mark - Generalization
|
||||
|
||||
- (void)textFieldProbablyDidChangeSelection
|
||||
@@ -330,6 +335,11 @@ - (void)skipNextTextInputDidChangeSelectionEventWithTextRange:(UITextRange *)tex
|
||||
_previousSelectedTextRange = textRange;
|
||||
}
|
||||
|
||||
+- (void)didPaste:(NSString *)type withData:(NSString *)data
|
||||
+{
|
||||
+ [_backedTextInputView.textInputDelegate textInputDidPaste:type withData:data];
|
||||
+}
|
||||
+
|
||||
#pragma mark - Generalization
|
||||
|
||||
- (void)textViewProbablyDidChangeSelection
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h
|
||||
index 4804624..90b7081 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h
|
||||
@@ -37,6 +37,7 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
@property (nonatomic, copy, nullable) RCTDirectEventBlock onChange;
|
||||
@property (nonatomic, copy, nullable) RCTDirectEventBlock onChangeSync;
|
||||
@property (nonatomic, copy, nullable) RCTDirectEventBlock onScroll;
|
||||
+@property (nonatomic, copy, nullable) RCTDirectEventBlock onPaste;
|
||||
|
||||
@property (nonatomic, assign) NSInteger mostRecentEventCount;
|
||||
@property (nonatomic, assign, readonly) NSInteger nativeEventCount;
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
|
||||
index 6a2d4f8..b6e6060 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
|
||||
@@ -599,6 +599,26 @@ - (void)textInputDidChangeSelection
|
||||
});
|
||||
}
|
||||
|
||||
+- (void)textInputDidPaste:(NSString *)type withData:(NSString *)data
|
||||
+{
|
||||
+ if (!_onPaste) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ NSMutableArray *items = [NSMutableArray new];
|
||||
+ [items addObject:@{
|
||||
+ @"type" : type,
|
||||
+ @"data" : data,
|
||||
+ }];
|
||||
+
|
||||
+ NSDictionary *payload = @{
|
||||
+ @"target" : self.reactTag,
|
||||
+ @"items" : items,
|
||||
+ };
|
||||
+
|
||||
+ _onPaste(payload);
|
||||
+}
|
||||
+
|
||||
- (void)updateLocalData
|
||||
{
|
||||
[self enforceTextAttributesIfNeeded];
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm
|
||||
index 47adc53..1865e0a 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm
|
||||
@@ -70,6 +70,7 @@ @implementation RCTBaseTextInputViewManager {
|
||||
RCT_EXPORT_VIEW_PROPERTY(onChangeSync, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onSelectionChange, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onScroll, RCTDirectEventBlock)
|
||||
+RCT_EXPORT_VIEW_PROPERTY(onPaste, RCTDirectEventBlock)
|
||||
|
||||
RCT_EXPORT_SHADOW_PROPERTY(text, NSString)
|
||||
RCT_EXPORT_SHADOW_PROPERTY(placeholder, NSString)
|
||||
diff --git a/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm b/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm
|
||||
index 377f41e..b8f48e6 100644
|
||||
--- a/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm
|
||||
+++ b/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTUITextField.mm
|
||||
@@ -12,6 +12,10 @@
|
||||
#import <React/RCTUtils.h>
|
||||
#import <React/UIView+React.h>
|
||||
|
||||
+#import <MobileCoreServices/MobileCoreServices.h>
|
||||
+#import <MobileCoreServices/UTType.h>
|
||||
+#import <UIKit/UIKit.h>
|
||||
+
|
||||
@implementation RCTUITextField {
|
||||
RCTBackedTextFieldDelegateAdapter *_textInputDelegateAdapter;
|
||||
NSDictionary<NSAttributedStringKey, id> *_defaultTextAttributes;
|
||||
@@ -180,6 +184,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender
|
||||
return NO;
|
||||
}
|
||||
|
||||
+ if (action == @selector(paste:) && [UIPasteboard generalPasteboard].hasImages) {
|
||||
+ return YES;
|
||||
+ }
|
||||
+
|
||||
return [super canPerformAction:action withSender:sender];
|
||||
}
|
||||
|
||||
@@ -263,7 +271,31 @@ - (void)scrollRangeToVisible:(NSRange)range
|
||||
- (void)paste:(id)sender
|
||||
{
|
||||
_textWasPasted = YES;
|
||||
- [super paste:sender];
|
||||
+ UIPasteboard *clipboard = [UIPasteboard generalPasteboard];
|
||||
+ if (clipboard.hasImages) {
|
||||
+ for (NSItemProvider *itemProvider in clipboard.itemProviders) {
|
||||
+ if ([itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) {
|
||||
+ for (NSString *identifier in itemProvider.registeredTypeIdentifiers) {
|
||||
+ if (UTTypeConformsTo((__bridge CFStringRef)identifier, kUTTypeImage)) {
|
||||
+ NSString *MIMEType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassMIMEType);
|
||||
+ NSString *fileExtension = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass((__bridge CFStringRef)identifier, kUTTagClassFilenameExtension);
|
||||
+ NSString *filePath = RCTTempFilePath(fileExtension, nil);
|
||||
+ NSURL *fileURL = [NSURL fileURLWithPath:filePath];
|
||||
+ NSData *fileData = [clipboard dataForPasteboardType:identifier];
|
||||
+ [fileData writeToFile:filePath atomically:YES];
|
||||
+ [_textInputDelegateAdapter didPaste:MIMEType withData:[fileURL absoluteString]];
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ } else {
|
||||
+ if (clipboard.hasStrings) {
|
||||
+ [_textInputDelegateAdapter didPaste:@"text/plain" withData:clipboard.string];
|
||||
+ }
|
||||
+ [super paste:sender];
|
||||
+ }
|
||||
}
|
||||
|
||||
#pragma mark - Layout
|
||||
diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
index 577bebe..bbde7fd 100644
|
||||
--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
@@ -516,6 +516,13 @@ - (void)textInputDidChangeSelection
|
||||
}
|
||||
}
|
||||
|
||||
+- (void)textInputDidPaste:(NSString *)type withData:(NSString *)data
|
||||
+{
|
||||
+ if (_eventEmitter) {
|
||||
+ static_cast<const TextInputEventEmitter &>(*_eventEmitter).onPaste(std::string([type UTF8String]), std::string([data UTF8String]));
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
#pragma mark - RCTBackedTextInputDelegate (UIScrollViewDelegate)
|
||||
|
||||
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
|
||||
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/PasteWatcher.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/PasteWatcher.kt
|
||||
new file mode 100644
|
||||
index 0000000..a684b87
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/PasteWatcher.kt
|
||||
@@ -0,0 +1,17 @@
|
||||
+/*
|
||||
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
+ *
|
||||
+ * This source code is licensed under the MIT license found in the
|
||||
+ * LICENSE file in the root directory of this source tree.
|
||||
+ */
|
||||
+
|
||||
+package com.facebook.react.views.textinput
|
||||
+
|
||||
+/**
|
||||
+ * Implement this interface to be informed of paste event in the
|
||||
+ * ReactTextEdit This is used by the ReactTextInputManager to forward events
|
||||
+ * from the EditText to JS
|
||||
+ */
|
||||
+public fun interface PasteWatcher {
|
||||
+ public fun onPaste(type: String, data: String)
|
||||
+}
|
||||
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt
|
||||
index 42f6e03..158e4e4 100644
|
||||
--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt
|
||||
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.kt
|
||||
@@ -8,6 +8,10 @@
|
||||
package com.facebook.react.views.textinput
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
+import android.content.ClipboardManager
|
||||
+import android.content.ClipData
|
||||
+import android.content.ClipDescription
|
||||
+import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Canvas
|
||||
@@ -15,7 +19,12 @@ import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
+import android.net.Uri
|
||||
+import com.facebook.react.uimanager.UIManagerHelper.getReactContext
|
||||
import android.os.Build
|
||||
+import java.io.File
|
||||
+import java.io.FileOutputStream
|
||||
+import java.io.InputStream
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.InputType
|
||||
@@ -128,6 +137,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat
|
||||
private var selectionWatcher: SelectionWatcher? = null
|
||||
private var contentSizeWatcher: ContentSizeWatcher? = null
|
||||
private var scrollWatcher: ScrollWatcher?
|
||||
+ private var pasteWatcher: PasteWatcher?
|
||||
private var keyListener: InternalKeyListener? = null
|
||||
private var detectScrollMovement = false
|
||||
private var onKeyPress = false
|
||||
@@ -212,6 +222,7 @@ public open class ReactEditText public constructor(context: Context) : AppCompat
|
||||
keyListener = InternalKeyListener()
|
||||
}
|
||||
scrollWatcher = null
|
||||
+ pasteWatcher = null
|
||||
textAttributes = TextAttributes()
|
||||
|
||||
applyTextAttributes()
|
||||
@@ -356,9 +367,57 @@ public open class ReactEditText public constructor(context: Context) : AppCompat
|
||||
* Called when a context menu option for the text view is selected.
|
||||
* React Native replaces copy (as rich text) with copy as plain text.
|
||||
*/
|
||||
- override fun onTextContextMenuItem(id: Int): Boolean =
|
||||
- super.onTextContextMenuItem(
|
||||
- if (id == android.R.id.paste) android.R.id.pasteAsPlainText else id)
|
||||
+ override fun onTextContextMenuItem(id: Int): Boolean {
|
||||
+ val modifiedId = if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) {
|
||||
+ android.R.id.pasteAsPlainText
|
||||
+ } else {
|
||||
+ id
|
||||
+ }
|
||||
+
|
||||
+ if (modifiedId == android.R.id.pasteAsPlainText) {
|
||||
+ val clipboardManager = getContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
+ val clipData = clipboardManager.primaryClip
|
||||
+ if (clipData != null) {
|
||||
+ val item = clipData.getItemAt(0)
|
||||
+ val itemUri = item.uri
|
||||
+ var type: String? = null
|
||||
+ var data: String? = null
|
||||
+
|
||||
+ if (itemUri != null) {
|
||||
+ // First try to get MIME type from ClipData description (more reliable for FileProvider URIs)
|
||||
+ type = if (clipData.description.mimeTypeCount > 0) {
|
||||
+ clipData.description.getMimeType(0)
|
||||
+ } else {
|
||||
+ // Fall back to ContentResolver
|
||||
+ val cr = getReactContext(this).contentResolver
|
||||
+ cr.getType(itemUri)
|
||||
+ }
|
||||
+ if (type != null && type != ClipDescription.MIMETYPE_TEXT_PLAIN) {
|
||||
+ // Copy content URI to cache and get file:// URI
|
||||
+ data = copyContentUriToCache(itemUri, type)
|
||||
+ if (data != null && pasteWatcher != null) {
|
||||
+ pasteWatcher?.onPaste(type, data)
|
||||
+ }
|
||||
+ // Prevents default behavior to avoid inserting raw binary data into the text field
|
||||
+ return true
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if (clipData.description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) {
|
||||
+ type = ClipDescription.MIMETYPE_TEXT_PLAIN
|
||||
+ val text: CharSequence? = item.text
|
||||
+ if (text != null) {
|
||||
+ data = text.toString()
|
||||
+ if (pasteWatcher != null) {
|
||||
+ pasteWatcher?.onPaste(type, data)
|
||||
+ }
|
||||
+ // Don't return - let the system proceed with default text pasting behavior
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ return super.onTextContextMenuItem(id)
|
||||
+ }
|
||||
|
||||
internal fun clearFocusAndMaybeRefocus() {
|
||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P || !isInTouchMode) {
|
||||
@@ -421,6 +480,45 @@ public open class ReactEditText public constructor(context: Context) : AppCompat
|
||||
this.scrollWatcher = scrollWatcher
|
||||
}
|
||||
|
||||
+ public fun setPasteWatcher(pasteWatcher: PasteWatcher?) {
|
||||
+ this.pasteWatcher = pasteWatcher
|
||||
+ }
|
||||
+
|
||||
+ /**
|
||||
+ * Copies a content URI to the cache directory and returns a file:// URI
|
||||
+ */
|
||||
+ private fun copyContentUriToCache(contentUri: Uri, mimeType: String?): String? {
|
||||
+ try {
|
||||
+ val cr = getReactContext(this).contentResolver
|
||||
+ val inputStream: InputStream = cr.openInputStream(contentUri) ?: return null
|
||||
+
|
||||
+ // Generate filename with appropriate extension
|
||||
+ val extension = when (mimeType) {
|
||||
+ "image/jpeg", "image/jpg" -> "jpg"
|
||||
+ "image/png" -> "png"
|
||||
+ "image/gif" -> "gif"
|
||||
+ "image/webp" -> "webp"
|
||||
+ else -> "jpg"
|
||||
+ }
|
||||
+ val fileName = "paste_${System.currentTimeMillis()}.$extension"
|
||||
+
|
||||
+ val cacheDir = context.cacheDir
|
||||
+ val outputFile = File(cacheDir, fileName)
|
||||
+ val outputStream = FileOutputStream(outputFile)
|
||||
+
|
||||
+ inputStream.use { input ->
|
||||
+ outputStream.use { output ->
|
||||
+ input.copyTo(output)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ return "file://${outputFile.absolutePath}"
|
||||
+ } catch (e: Exception) {
|
||||
+ e.printStackTrace()
|
||||
+ return null
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
/**
|
||||
* Attempt to set a selection or fail silently. Intentionally meant to handle bad inputs.
|
||||
* EventCounter is the same one used as with text.
|
||||
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt
|
||||
index 42c13a1..c39e240 100644
|
||||
--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt
|
||||
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.kt
|
||||
@@ -133,6 +133,8 @@ public open class ReactTextInputManager public constructor() :
|
||||
val eventTypeConstants = baseEventTypeConstants ?: mutableMapOf()
|
||||
eventTypeConstants.putAll(
|
||||
mapOf(getJSEventName(ScrollEventType.SCROLL) to mapOf("registrationName" to "onScroll")))
|
||||
+ eventTypeConstants.putAll(
|
||||
+ mapOf("topPaste" to mapOf("registrationName" to "onPaste")))
|
||||
return eventTypeConstants
|
||||
}
|
||||
|
||||
@@ -327,6 +329,15 @@ public open class ReactTextInputManager public constructor() :
|
||||
}
|
||||
}
|
||||
|
||||
+ @ReactProp(name = "onPaste", defaultBoolean = false)
|
||||
+ public fun setOnPaste(view: ReactEditText, onPaste: Boolean) {
|
||||
+ if (onPaste) {
|
||||
+ view.setPasteWatcher(ReactPasteWatcher(view))
|
||||
+ } else {
|
||||
+ view.setPasteWatcher(null)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
@ReactProp(name = "onKeyPress", defaultBoolean = false)
|
||||
public fun setOnKeyPress(view: ReactEditText, onKeyPress: Boolean) {
|
||||
view.setOnKeyPress(onKeyPress)
|
||||
@@ -944,6 +955,24 @@ public open class ReactTextInputManager public constructor() :
|
||||
}
|
||||
}
|
||||
|
||||
+ private class ReactPasteWatcher(editText: ReactEditText) : PasteWatcher {
|
||||
+ private val mReactEditText: ReactEditText = editText
|
||||
+ private val mEventDispatcher: EventDispatcher?
|
||||
+ private val mSurfaceId: Int
|
||||
+
|
||||
+ init {
|
||||
+ val reactContext = UIManagerHelper.getReactContext(editText)
|
||||
+ mEventDispatcher = getEventDispatcher(reactContext, editText)
|
||||
+ mSurfaceId = UIManagerHelper.getSurfaceId(reactContext)
|
||||
+ }
|
||||
+
|
||||
+ override fun onPaste(type: String, data: String) {
|
||||
+ mEventDispatcher?.dispatchEvent(
|
||||
+ ReactTextInputPasteEvent(mSurfaceId, mReactEditText.id, type, data)
|
||||
+ )
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
override fun getExportedViewConstants(): Map<String, Any> =
|
||||
mapOf(
|
||||
"AutoCapitalizationType" to
|
||||
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputPasteEvent.kt b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputPasteEvent.kt
|
||||
new file mode 100644
|
||||
index 0000000..6f5b10b
|
||||
--- /dev/null
|
||||
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputPasteEvent.kt
|
||||
@@ -0,0 +1,61 @@
|
||||
+/*
|
||||
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
+ *
|
||||
+ * This source code is licensed under the MIT license found in the
|
||||
+ * LICENSE file in the root directory of this source tree.
|
||||
+ */
|
||||
+
|
||||
+package com.facebook.react.views.textinput
|
||||
+
|
||||
+import androidx.annotation.Nullable
|
||||
+import com.facebook.react.bridge.Arguments
|
||||
+import com.facebook.react.bridge.WritableMap
|
||||
+import com.facebook.react.bridge.WritableArray
|
||||
+import com.facebook.react.uimanager.common.ViewUtil
|
||||
+import com.facebook.react.uimanager.events.Event
|
||||
+
|
||||
+/**
|
||||
+ * Event emitted by EditText native view when clipboard content is pasted
|
||||
+ */
|
||||
+public class ReactTextInputPasteEvent : Event<ReactTextInputPasteEvent> {
|
||||
+
|
||||
+ public companion object {
|
||||
+ private const val EVENT_NAME = "topPaste"
|
||||
+ }
|
||||
+
|
||||
+ private val mType: String
|
||||
+ private val mData: String
|
||||
+
|
||||
+ @Deprecated("Use constructor with surfaceId")
|
||||
+ public constructor(viewId: Int, type: String, data: String) :
|
||||
+ this(ViewUtil.NO_SURFACE_ID, viewId, type, data)
|
||||
+
|
||||
+ public constructor(surfaceId: Int, viewId: Int, type: String, data: String) :
|
||||
+ super(surfaceId, viewId) {
|
||||
+ mType = type
|
||||
+ mData = data
|
||||
+ }
|
||||
+
|
||||
+ override fun getEventName(): String {
|
||||
+ return EVENT_NAME
|
||||
+ }
|
||||
+
|
||||
+ override fun canCoalesce(): Boolean {
|
||||
+ return false
|
||||
+ }
|
||||
+
|
||||
+ @Nullable
|
||||
+ override fun getEventData(): WritableMap? {
|
||||
+ val eventData = Arguments.createMap()
|
||||
+
|
||||
+ val items: WritableArray = Arguments.createArray()
|
||||
+ val item: WritableMap = Arguments.createMap()
|
||||
+ item.putString("type", mType)
|
||||
+ item.putString("data", mData)
|
||||
+ items.pushMap(item)
|
||||
+
|
||||
+ eventData.putArray("items", items)
|
||||
+
|
||||
+ return eventData
|
||||
+ }
|
||||
+}
|
||||
diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
index a9bc219..7ebec12 100644
|
||||
--- a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
+++ b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
@@ -177,6 +177,19 @@ void TextInputEventEmitter::onScroll(const Metrics& textInputMetrics) const {
|
||||
});
|
||||
}
|
||||
|
||||
+void TextInputEventEmitter::onPaste(const std::string& type, const std::string& data) const {
|
||||
+ dispatchEvent("onPaste", [type, data](jsi::Runtime& runtime) {
|
||||
+ auto payload = jsi::Object(runtime);
|
||||
+ auto items = jsi::Array(runtime, 1);
|
||||
+ auto item = jsi::Object(runtime);
|
||||
+ item.setProperty(runtime, "type", type);
|
||||
+ item.setProperty(runtime, "data", data);
|
||||
+ items.setValueAtIndex(runtime, 0, item);
|
||||
+ payload.setProperty(runtime, "items", items);
|
||||
+ return payload;
|
||||
+ });
|
||||
+}
|
||||
+
|
||||
void TextInputEventEmitter::dispatchTextInputEvent(
|
||||
const std::string& name,
|
||||
const Metrics& textInputMetrics,
|
||||
diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
index dbce575..5b03581 100644
|
||||
--- a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
+++ b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
@@ -44,6 +44,7 @@ class TextInputEventEmitter : public ViewEventEmitter {
|
||||
void onSubmitEditing(const Metrics& textInputMetrics) const;
|
||||
void onKeyPress(const KeyPressMetrics& keyPressMetrics) const;
|
||||
void onScroll(const Metrics& textInputMetrics) const;
|
||||
+ void onPaste(const std::string& type, const std::string& data) const;
|
||||
|
||||
private:
|
||||
void dispatchTextInputEvent(
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js b/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
|
||||
index efa71f9..a23c624 100644
|
||||
index 40fdcdb..770fd67 100644
|
||||
--- a/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
|
||||
+++ b/node_modules/react-native-drawer-layout/lib/module/views/Drawer.native.js
|
||||
@@ -124,15 +124,21 @@ export function Drawer({
|
||||
@@ -31,7 +31,7 @@ index efa71f9..a23c624 100644
|
||||
- }, finished => runOnJS(onAnimationEnd)(open, finished));
|
||||
+ }, finished => {
|
||||
+ animatingTo.value = null;
|
||||
+ runOnJS(onAnimationEnd)(open, finished)
|
||||
+ runOnJS(onAnimationEnd)(open, finished);
|
||||
+ });
|
||||
if (open) {
|
||||
runOnJS(onOpen)();
|
||||
@@ -1,326 +0,0 @@
|
||||
diff --git a/node_modules/react-native-screens/ios/RNSScreen.mm b/node_modules/react-native-screens/ios/RNSScreen.mm
|
||||
index b62a2e2..cb469db 100644
|
||||
--- a/node_modules/react-native-screens/ios/RNSScreen.mm
|
||||
+++ b/node_modules/react-native-screens/ios/RNSScreen.mm
|
||||
@@ -729,9 +729,26 @@ - (void)notifyTransitionProgress:(double)progress closing:(BOOL)closing goingFor
|
||||
#endif
|
||||
}
|
||||
|
||||
-#if !RCT_NEW_ARCH_ENABLED
|
||||
+- (void)willMoveToWindow:(UIWindow *)newWindow
|
||||
+{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // In iOS 26, as soon as another screen appears in transition, it is interactable
|
||||
+ // To avoid glitches resulting from clicking buttons mid transition, we temporarily disable all interactions
|
||||
+ // Disabling interactions for parent navigation controller won't be enough in case of nested stack
|
||||
+ // Furthermore, a stack put inside a modal will exist in an entirely different hierarchy
|
||||
+ // To be sure, we block interactions on the whole window.
|
||||
+ // Note that newWindows is nil when moving from instead of moving to, and Obj-C handles nil correctly
|
||||
+ newWindow.userInteractionEnabled = false;
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
- (void)presentationControllerWillDismiss:(UIPresentationController *)presentationController
|
||||
{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // Disable interactions to disallow multiple modals dismissed at once; see willMoveToWindow
|
||||
+ presentationController.containerView.window.userInteractionEnabled = false;
|
||||
+ }
|
||||
+#if !RCT_NEW_ARCH_ENABLED
|
||||
// On Paper, we need to call both "cancel" and "reset" here because RN's gesture
|
||||
// recognizer does not handle the scenario when it gets cancelled by other top
|
||||
// level gesture recognizer. In this case by the modal dismiss gesture.
|
||||
@@ -744,8 +761,8 @@ - (void)presentationControllerWillDismiss:(UIPresentationController *)presentati
|
||||
// down.
|
||||
[_touchHandler cancel];
|
||||
[_touchHandler reset];
|
||||
-}
|
||||
#endif // !RCT_NEW_ARCH_ENABLED
|
||||
+}
|
||||
|
||||
- (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presentationController
|
||||
{
|
||||
@@ -757,6 +774,10 @@ - (BOOL)presentationControllerShouldDismiss:(UIPresentationController *)presenta
|
||||
|
||||
- (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)presentationController
|
||||
{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // Reenable interactions; see presentationControllerWillDismiss
|
||||
+ presentationController.containerView.window.userInteractionEnabled = true;
|
||||
+ }
|
||||
// NOTE(kkafar): We should consider depracating the use of gesture cancel here & align
|
||||
// with usePreventRemove API of react-navigation v7.
|
||||
[self notifyGestureCancel];
|
||||
@@ -767,6 +788,11 @@ - (void)presentationControllerDidAttemptToDismiss:(UIPresentationController *)pr
|
||||
|
||||
- (void)presentationControllerDidDismiss:(UIPresentationController *)presentationController
|
||||
{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // Reenable interactions; see presentationControllerWillDismiss
|
||||
+ // Dismissed screen doesn't hold a reference to window, but presentingViewController.view does
|
||||
+ presentationController.presentingViewController.view.window.userInteractionEnabled = true;
|
||||
+ }
|
||||
if ([_reactSuperview respondsToSelector:@selector(presentationControllerDidDismiss:)]) {
|
||||
[_reactSuperview performSelector:@selector(presentationControllerDidDismiss:) withObject:presentationController];
|
||||
}
|
||||
@@ -1518,6 +1544,10 @@ - (void)viewWillDisappear:(BOOL)animated
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated
|
||||
{
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // Reenable interactions, see willMoveToWindow
|
||||
+ self.view.window.userInteractionEnabled = true;
|
||||
+ }
|
||||
[super viewDidAppear:animated];
|
||||
if (!_isSwiping || _shouldNotify) {
|
||||
// we are going forward or dismissing without swipe
|
||||
diff --git a/node_modules/react-native-screens/ios/RNSScreenStack.mm b/node_modules/react-native-screens/ios/RNSScreenStack.mm
|
||||
index 229dc58..10b365b 100644
|
||||
--- a/node_modules/react-native-screens/ios/RNSScreenStack.mm
|
||||
+++ b/node_modules/react-native-screens/ios/RNSScreenStack.mm
|
||||
@@ -62,26 +62,6 @@ @interface RNSScreenStackView () <
|
||||
|
||||
@implementation RNSNavigationController
|
||||
|
||||
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
|
||||
-- (void)viewDidLoad
|
||||
-{
|
||||
- // iOS 26 introduces new gesture recognizer which replaces our RNSPanGestureRecognizer.
|
||||
- // The problem is that we are not able to handle it here for various reasons:
|
||||
- // - the new recognizer comes with its own delegate and our current approach is to wire
|
||||
- // all recognizers to RNSScreenStackView; to be 100% sure we don't break the logic,
|
||||
- // we would have to decorate its delegate and call it after our code, which would
|
||||
- // break other recognizers that the stack view is the delegate for
|
||||
- // - when RNSScreenStackView.setupGestureHandler method is called, the recognizer hasn't been
|
||||
- // loaded yet and there is no other place to configure in a not "hacky" way
|
||||
- // - the official docs warn us to not use it for anything other than "setting up failure requirements with it"
|
||||
- // - we expose fullScreenGestureEnabled prop to enable/disable the feature,
|
||||
- // so we need control over the delegate
|
||||
- if (@available(iOS 26.0, *)) {
|
||||
- self.interactiveContentPopGestureRecognizer.enabled = NO;
|
||||
- }
|
||||
-}
|
||||
-#endif // iOS 26
|
||||
-
|
||||
#if !TARGET_OS_TV
|
||||
- (UIViewController *)childViewControllerForStatusBarStyle
|
||||
{
|
||||
@@ -219,50 +199,6 @@ - (bool)onRepeatedTabSelectionOfTabScreenController:(RNSTabsScreenViewController
|
||||
return false;
|
||||
}
|
||||
|
||||
-#pragma mark - UINavigationBarDelegate
|
||||
-
|
||||
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
|
||||
-- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item
|
||||
-{
|
||||
- if (@available(iOS 26, *)) {
|
||||
- // To prevent popping multiple screens when back button is pressed repeatedly,
|
||||
- // We allow for pop operation to proceed only if no transition is in progress,
|
||||
- // which we check indirectly by checking if transitionCoordinator is set.
|
||||
- // If it's not, we are safe to proceed.
|
||||
- if (self.transitionCoordinator == nil) {
|
||||
- // We still need to disable interactions for back button so click effects are not applied,
|
||||
- // and there is unfortunately no better place for it currently
|
||||
- UIView *button = [navigationBar rnscreens_findBackButtonWrapperView];
|
||||
- if (button != nil) {
|
||||
- button.userInteractionEnabled = false;
|
||||
- }
|
||||
-
|
||||
- return true;
|
||||
- }
|
||||
-
|
||||
- return false;
|
||||
- }
|
||||
-
|
||||
- return true;
|
||||
-}
|
||||
-
|
||||
-- (void)navigationBar:(UINavigationBar *)navigationBar didPopItem:(UINavigationItem *)item
|
||||
-{
|
||||
- if (@available(iOS 26, *)) {
|
||||
- // Reset interactions on back button -> see navigationBar:shouldPopItem
|
||||
- // IMPORTANT: This reset won't execute when preventNativeDismiss is on.
|
||||
- // However, on iOS 26, unlike in previous versions, the back button instance changes
|
||||
- // when handling preventNativeDismiss and userIteractionEnabled is reset.
|
||||
- // The instance also changes when regular screen pop happens, but in that case
|
||||
- // the value of userInteractionEnabled is carried on, and we reset it here.
|
||||
- UIView *button = [navigationBar rnscreens_findBackButtonWrapperView];
|
||||
- if (button != nil) {
|
||||
- button.userInteractionEnabled = true;
|
||||
- }
|
||||
- }
|
||||
-}
|
||||
-#endif // Check for iOS >= 26
|
||||
-
|
||||
#pragma mark - RNSFrameCorrectionProvider
|
||||
|
||||
#ifdef RNS_GAMMA_ENABLED
|
||||
@@ -327,7 +263,7 @@ @implementation RNSScreenStackView {
|
||||
UINavigationController *_controller;
|
||||
NSMutableArray<RNSScreenView *> *_reactSubviews;
|
||||
BOOL _invalidated;
|
||||
- BOOL _isFullWidthSwiping;
|
||||
+ BOOL _isFullWidthSwipingWithPanGesture; // used only for content swipe with RNSPanGestureRecognizer
|
||||
RNSPercentDrivenInteractiveTransition *_interactionController;
|
||||
__weak RNSScreenStackManager *_manager;
|
||||
BOOL _updateScheduled;
|
||||
@@ -522,6 +458,11 @@ - (void)reactAddControllerToClosestParent:(UIViewController *)controller
|
||||
[self addSubview:controller.view];
|
||||
#if !TARGET_OS_TV
|
||||
_controller.interactivePopGestureRecognizer.delegate = self;
|
||||
+ #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ _controller.interactiveContentPopGestureRecognizer.delegate = self;
|
||||
+ }
|
||||
+#endif // Check for iOS >= 26.0
|
||||
#endif
|
||||
[controller didMoveToParentViewController:parentView.reactViewController];
|
||||
// On iOS pre 12 we observed that `willShowViewController` delegate method does not always
|
||||
@@ -943,7 +884,7 @@ - (void)dismissOnReload
|
||||
// when preventing the native dismiss with back button, we have to return the animator.
|
||||
// Also, we need to return the animator when full width swiping even if the animation is not custom,
|
||||
// otherwise the screen will be just popped immediately due to no animation
|
||||
- ((operation == UINavigationControllerOperationPop && shouldCancelDismiss) || _isFullWidthSwiping ||
|
||||
+ ((operation == UINavigationControllerOperationPop && shouldCancelDismiss) || _isFullWidthSwipingWithPanGesture ||
|
||||
[RNSScreenStackAnimator isCustomAnimation:screen.stackAnimation] || _customAnimation)) {
|
||||
return [[RNSScreenStackAnimator alloc] initWithOperation:operation];
|
||||
}
|
||||
@@ -967,23 +908,39 @@ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
|
||||
}
|
||||
RNSScreenView *topScreen = _reactSubviews.lastObject;
|
||||
|
||||
+ BOOL customAnimationOnSwipePropSetAndSelectedAnimationIsCustom =
|
||||
+ topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation];
|
||||
+
|
||||
#if TARGET_OS_TV || TARGET_OS_VISION
|
||||
[self cancelTouchesInParent];
|
||||
return YES;
|
||||
#else
|
||||
- // RNSPanGestureRecognizer will receive events iff topScreen.fullScreenSwipeEnabled == YES;
|
||||
- // Events are filtered in gestureRecognizer:shouldReceivePressOrTouchEvent: method
|
||||
if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
|
||||
- if ([self isInGestureResponseDistance:gestureRecognizer topScreen:topScreen]) {
|
||||
- _isFullWidthSwiping = YES;
|
||||
- [self cancelTouchesInParent];
|
||||
- return YES;
|
||||
+ // On iOS < 26, we have a custom full screen swipe recognizer that functions similarily
|
||||
+ // to interactiveContentPopGestureRecognizer introduced in iOS 26.
|
||||
+ // On iOS >= 26, we want to use the native one, but we are unable to handle custom animations
|
||||
+ // with native interactiveContentPopGestureRecognizer, so we have to fallback to the old implementation.
|
||||
+ // In this case, the old one should behave as close as the new native one, having only the difference
|
||||
+ // in animation, and without any customization that is exclusive for it (e.g. gestureResponseDistance).
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ if (customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) {
|
||||
+ _isFullWidthSwipingWithPanGesture = YES;
|
||||
+ [self cancelTouchesInParent];
|
||||
+ return YES;
|
||||
+ }
|
||||
+ return NO;
|
||||
+ } else {
|
||||
+ if ([self isInGestureResponseDistance:gestureRecognizer topScreen:topScreen]) {
|
||||
+ _isFullWidthSwipingWithPanGesture = YES;
|
||||
+ [self cancelTouchesInParent];
|
||||
+ return YES;
|
||||
+ }
|
||||
+ return NO;
|
||||
}
|
||||
- return NO;
|
||||
}
|
||||
|
||||
// Now we're dealing with RNSScreenEdgeGestureRecognizer (or _UIParallaxTransitionPanGestureRecognizer)
|
||||
- if (topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation]) {
|
||||
+ if (customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) {
|
||||
if ([gestureRecognizer isKindOfClass:[RNSScreenEdgeGestureRecognizer class]]) {
|
||||
UIRectEdge edges = ((RNSScreenEdgeGestureRecognizer *)gestureRecognizer).edges;
|
||||
BOOL isRTL = _controller.view.semanticContentAttribute == UISemanticContentAttributeForceRightToLeft;
|
||||
@@ -1028,7 +985,9 @@ - (void)setupGestureHandlers
|
||||
rightEdgeSwipeGestureRecognizer.delegate = self;
|
||||
[self addGestureRecognizer:rightEdgeSwipeGestureRecognizer];
|
||||
|
||||
- // gesture recognizer for full width swipe gesture
|
||||
+ // Starting from iOS 26, RNSPanGestureRecognizer has been mostly replaced by native
|
||||
+ // interactiveContentPopGestureRecognizer. It still needs to handle custom dismiss animations,
|
||||
+ // which we are not able to handle with the latter.
|
||||
RNSPanGestureRecognizer *panRecognizer = [[RNSPanGestureRecognizer alloc] initWithTarget:self
|
||||
action:@selector(handleSwipe:)];
|
||||
panRecognizer.delegate = self;
|
||||
@@ -1091,7 +1050,7 @@ - (void)handleSwipe:(UIPanGestureRecognizer *)gestureRecognizer
|
||||
[_interactionController cancelInteractiveTransition];
|
||||
}
|
||||
_interactionController = nil;
|
||||
- _isFullWidthSwiping = NO;
|
||||
+ _isFullWidthSwipingWithPanGesture = NO;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
@@ -1225,14 +1184,6 @@ - (BOOL)isScrollViewPanGestureRecognizer:(UIGestureRecognizer *)gestureRecognize
|
||||
// Be careful when adding another type of gesture recognizer.
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceivePressOrTouchEvent:(NSObject *)event
|
||||
{
|
||||
- if (@available(iOS 26, *)) {
|
||||
- // in iOS 26, you can swipe to pop screen before the previous one finished transitioning;
|
||||
- // this prevents from registering the second gesture
|
||||
- if ([self isTransitionInProgress]) {
|
||||
- return NO;
|
||||
- }
|
||||
- }
|
||||
-
|
||||
RNSScreenView *topScreen = _reactSubviews.lastObject;
|
||||
|
||||
for (RNSScreenView *s in _reactSubviews.reverseObjectEnumerator) {
|
||||
@@ -1249,10 +1200,30 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceive
|
||||
return NO;
|
||||
}
|
||||
|
||||
+ BOOL customAnimationOnSwipePropSetAndSelectedAnimationIsCustom =
|
||||
+ topScreen.customAnimationOnSwipe && [RNSScreenStackAnimator isCustomAnimation:topScreen.stackAnimation];
|
||||
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
|
||||
+ if (@available(iOS 26, *)) {
|
||||
+ // On iOS 26, fullScreenSwipeEnabled takes no effect, and depending on whether custom animations are on,
|
||||
+ // we select either interactiveContentPopGestureRecognizer or RNSPanGestureRecognizer
|
||||
+ if (([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]] &&
|
||||
+ !customAnimationOnSwipePropSetAndSelectedAnimationIsCustom) ||
|
||||
+ (gestureRecognizer == _controller.interactiveContentPopGestureRecognizer &&
|
||||
+ customAnimationOnSwipePropSetAndSelectedAnimationIsCustom)) {
|
||||
+ return NO;
|
||||
+ }
|
||||
+ } else {
|
||||
+ // We want to pass events to RNSPanGestureRecognizer iff full screen swipe is enabled.
|
||||
+ if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
|
||||
+ return topScreen.fullScreenSwipeEnabled;
|
||||
+ }
|
||||
+ }
|
||||
+#else // check for iOS >= 26
|
||||
// We want to pass events to RNSPanGestureRecognizer iff full screen swipe is enabled.
|
||||
if ([gestureRecognizer isKindOfClass:[RNSPanGestureRecognizer class]]) {
|
||||
return topScreen.fullScreenSwipeEnabled;
|
||||
}
|
||||
+#endif // check for iOS >= 26
|
||||
|
||||
// RNSScreenEdgeGestureRecognizer || _UIParallaxTransitionPanGestureRecognizer
|
||||
return YES;
|
||||
@@ -1268,15 +1239,6 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceive
|
||||
return [self gestureRecognizer:gestureRecognizer shouldReceivePressOrTouchEvent:touch];
|
||||
}
|
||||
|
||||
-- (BOOL)isTransitionInProgress
|
||||
-{
|
||||
- if (_controller.transitionCoordinator != nil) {
|
||||
- return YES;
|
||||
- }
|
||||
-
|
||||
- return NO;
|
||||
-}
|
||||
-
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
|
||||
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
|
||||
{
|
||||
@@ -1289,7 +1251,6 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
|
||||
if (gestureRecognizer.state == UIGestureRecognizerStateBegan || isBackGesture) {
|
||||
return NO;
|
||||
}
|
||||
-
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
@@ -61,7 +61,6 @@ import {Shell} from '#/view/shell'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
@@ -165,7 +164,6 @@ function InnerApp() {
|
||||
<IntentDialogProvider>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</GlobalGestureEventsProvider>
|
||||
|
||||
@@ -48,7 +48,6 @@ import {Shell} from '#/view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
|
||||
@@ -138,7 +137,6 @@ function InnerApp() {
|
||||
<HideBottomBarBorderProvider>
|
||||
<IntentDialogProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
<ToastOutlet />
|
||||
</IntentDialogProvider>
|
||||
</HideBottomBarBorderProvider>
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {timeout} from '#/lib/async/timeout'
|
||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {
|
||||
getNotificationPayload,
|
||||
type NotificationPayload,
|
||||
@@ -45,10 +47,12 @@ import {logger} from '#/logger'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {
|
||||
shouldRequestEmailConfirmation,
|
||||
snoozeEmailConfirmationPrompt,
|
||||
} from '#/state/shell/reminders'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy'
|
||||
import {DebugModScreen} from '#/view/screens/DebugMod'
|
||||
@@ -64,13 +68,14 @@ import {PostThreadScreen} from '#/view/screens/PostThread'
|
||||
import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy'
|
||||
import {ProfileScreen} from '#/view/screens/Profile'
|
||||
import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy'
|
||||
import {Storybook} from '#/view/screens/Storybook'
|
||||
import {StorybookScreen} from '#/view/screens/Storybook'
|
||||
import {SupportScreen} from '#/view/screens/Support'
|
||||
import {TermsOfServiceScreen} from '#/view/screens/TermsOfService'
|
||||
import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {BookmarksScreen} from '#/screens/Bookmarks'
|
||||
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
||||
import {FindContactsFlowScreen} from '#/screens/FindContactsFlowScreen'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {LogScreen} from '#/screens/Log'
|
||||
import {MessagesScreen} from '#/screens/Messages/ChatList'
|
||||
@@ -102,6 +107,7 @@ import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
|
||||
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
|
||||
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
|
||||
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
|
||||
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
|
||||
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
|
||||
import {InterestsSettingsScreen} from '#/screens/Settings/InterestsSettings'
|
||||
import {LanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
|
||||
@@ -134,10 +140,6 @@ import {
|
||||
} from '#/components/dialogs/EmailDialog'
|
||||
import {router} from '#/routes'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {useAccountSwitcher} from './lib/hooks/useAccountSwitcher'
|
||||
import {useNonReactiveCallback} from './lib/hooks/useNonReactiveCallback'
|
||||
import {useLoggedOutViewControls} from './state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from './state/util'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -302,7 +304,7 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Debug"
|
||||
getComponent={() => Storybook}
|
||||
getComponent={() => StorybookScreen}
|
||||
options={{title: title(msg`Storybook`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -416,6 +418,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="FindContactsSettings"
|
||||
getComponent={() => FindContactsSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Find Contacts`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NotificationSettings"
|
||||
getComponent={() => NotificationSettingsScreen}
|
||||
@@ -609,6 +619,15 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="FindContactsFlow"
|
||||
getComponent={() => FindContactsFlowScreen}
|
||||
options={{
|
||||
title: title(msg`Find Contacts`),
|
||||
requireAuth: true,
|
||||
gestureEnabled: false,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -617,7 +636,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
|
||||
* The TabsNavigator is used by native mobile to represent the routes
|
||||
* in 3 distinct tab-stacks with a different root screen on each.
|
||||
*/
|
||||
function TabsNavigator() {
|
||||
function TabsNavigator({
|
||||
layout,
|
||||
}: {
|
||||
layout: React.ComponentProps<typeof Tab.Navigator>['layout']
|
||||
}) {
|
||||
const tabBar = useCallback(
|
||||
(props: JSX.IntrinsicAttributes & BottomTabBarProps) => (
|
||||
<BottomBar {...props} />
|
||||
@@ -630,7 +653,8 @@ function TabsNavigator() {
|
||||
initialRouteName="HomeTab"
|
||||
backBehavior="initialRoute"
|
||||
screenOptions={{headerShown: false, lazy: true}}
|
||||
tabBar={tabBar}>
|
||||
tabBar={tabBar}
|
||||
layout={layout}>
|
||||
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
|
||||
<Tab.Screen
|
||||
@@ -738,7 +762,11 @@ function MessagesTabNavigator() {
|
||||
* The FlatNavigator is used by Web to represent the routes
|
||||
* in a single ("flat") stack.
|
||||
*/
|
||||
const FlatNavigator = () => {
|
||||
const FlatNavigator = ({
|
||||
layout,
|
||||
}: {
|
||||
layout: React.ComponentProps<typeof Flat.Navigator>['layout']
|
||||
}) => {
|
||||
const t = useTheme()
|
||||
const numUnread = useUnreadNotifications()
|
||||
const screenListeners = useWebScrollRestoration()
|
||||
@@ -746,6 +774,7 @@ const FlatNavigator = () => {
|
||||
|
||||
return (
|
||||
<Flat.Navigator
|
||||
layout={layout}
|
||||
screenListeners={screenListeners}
|
||||
screenOptions={screenOptions(t)}>
|
||||
<Flat.Screen
|
||||
|
||||
@@ -9,12 +9,11 @@ import {
|
||||
useCreateSupportLink,
|
||||
} from '#/lib/hooks/useCreateSupportLink'
|
||||
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {isAppPassword} from '#/lib/jwt'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
|
||||
import {useSession, useSessionApi} from '#/state/session'
|
||||
import {useSessionApi} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog'
|
||||
@@ -30,7 +29,7 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/ic
|
||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Span, Text} from '#/components/Typography'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
|
||||
@@ -55,9 +54,6 @@ export function NoAccessScreen() {
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const createSupportLink = useCreateSupportLink()
|
||||
|
||||
const {currentAccount} = useSession()
|
||||
const isUsingAppPassword = isAppPassword(currentAccount?.accessJwt || '')
|
||||
|
||||
const aa = useAgeAssurance()
|
||||
const isBlocked = aa.state.status === aa.Status.Blocked
|
||||
const isAARegion = !!region
|
||||
@@ -89,22 +85,38 @@ export function NoAccessScreen() {
|
||||
logoutCurrentAccount('AgeAssuranceNoAccessScreen')
|
||||
}, [logoutCurrentAccount])
|
||||
|
||||
const birthdateUpdateText = canUpdateBirthday ? (
|
||||
<Text style={[textStyles]}>
|
||||
const orgAdmonition = (
|
||||
<Admonition type="tip">
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
logger.metric('ageAssurance:noAccessScreen:openBirthdateDialog', {})
|
||||
birthdateControl.open()
|
||||
})}>
|
||||
clicking here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
For organizational accounts, use the birthdate of the person who is
|
||||
responsible for the account.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Admonition>
|
||||
)
|
||||
|
||||
const birthdateUpdateText = canUpdateBirthday ? (
|
||||
<>
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
If you believe your birthdate is incorrect, you can update it by{' '}
|
||||
<SimpleInlineLinkText
|
||||
label={_(msg`Click here to update your birthdate`)}
|
||||
style={[textStyles]}
|
||||
{...createStaticClick(() => {
|
||||
logger.metric(
|
||||
'ageAssurance:noAccessScreen:openBirthdateDialog',
|
||||
{},
|
||||
)
|
||||
birthdateControl.open()
|
||||
})}>
|
||||
clicking here
|
||||
</SimpleInlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{orgAdmonition}
|
||||
</>
|
||||
) : (
|
||||
<Text style={[textStyles]}>
|
||||
<Trans>
|
||||
@@ -211,16 +223,7 @@ export function NoAccessScreen() {
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
{isUsingAppPassword && (
|
||||
<Admonition type="info">
|
||||
<Trans>
|
||||
Hmm, it looks like you're logged in with an{' '}
|
||||
<Span style={[a.italic]}>App Password</Span>. To set your
|
||||
birthdate, you'll need to log in with your main account
|
||||
password, or ask whomever controls this account to do so.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
)}
|
||||
{orgAdmonition}
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, {useCallback, useEffect, useRef} from 'react'
|
||||
import {ScrollView, View} from 'react-native'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
import {type AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {type MetricEvents} from '#/logger/metrics'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
@@ -14,7 +15,10 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {
|
||||
useSuggestedFollowsByActorQuery,
|
||||
useSuggestedFollowsQuery,
|
||||
} from '#/state/queries/suggested-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import {type SeenPost} from '#/state/userActionHistory'
|
||||
@@ -31,6 +35,7 @@ import {useDialogControl} from '#/components/Dialog'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -38,6 +43,8 @@ import type * as bsky from '#/types/bsky'
|
||||
import {FollowDialogWithoutGuide} from './ProgressGuide/FollowDialog'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
|
||||
const DISMISS_ANIMATION_DURATION = 200
|
||||
|
||||
const MOBILE_CARD_WIDTH = 165
|
||||
const FINAL_CARD_WIDTH = 120
|
||||
|
||||
@@ -49,6 +56,7 @@ function CardOuter({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<View
|
||||
testID="CardOuter"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
@@ -202,6 +210,9 @@ export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||
}
|
||||
|
||||
export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const maxLength = gtMobile ? 4 : 6
|
||||
const {
|
||||
isLoading: isSuggestionsLoading,
|
||||
data,
|
||||
@@ -209,29 +220,194 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
} = useSuggestedFollowsByActorQuery({
|
||||
did,
|
||||
})
|
||||
const {
|
||||
data: moreSuggestions,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = React.useCallback((dismissedDid: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(dismissedDid))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(dismissedDid))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(dismissedDid)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from the actor-specific query with fallback suggestions
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const actorProfiles = data?.suggestions ?? []
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring actor-specific profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of actorProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.did) && profile.did !== did) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [data?.suggestions, moreSuggestions?.pages, did])
|
||||
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
maxLength,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
moderationOpts,
|
||||
])
|
||||
|
||||
return (
|
||||
<ProfileGrid
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={data?.suggestions ?? []}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
recId={data?.recId}
|
||||
error={error}
|
||||
viewContext="profile"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFollowsHome() {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const maxLength = gtMobile ? 4 : 6
|
||||
const {
|
||||
isLoading: isSuggestionsLoading,
|
||||
profiles,
|
||||
error,
|
||||
profiles: experimentalProfiles,
|
||||
error: experimentalError,
|
||||
} = useExperimentalSuggestedUsersQuery()
|
||||
const {
|
||||
data: moreSuggestions,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
error: suggestionsError,
|
||||
} = useSuggestedFollowsQuery({limit: 25})
|
||||
|
||||
const [dismissedDids, setDismissedDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
const [dismissingDids, setDismissingDids] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
)
|
||||
|
||||
const onDismiss = React.useCallback((did: string) => {
|
||||
// Start the fade animation
|
||||
setDismissingDids(prev => new Set(prev).add(did))
|
||||
// After animation completes, actually remove from list
|
||||
setTimeout(() => {
|
||||
setDismissedDids(prev => new Set(prev).add(did))
|
||||
setDismissingDids(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(did)
|
||||
return next
|
||||
})
|
||||
}, DISMISS_ANIMATION_DURATION)
|
||||
}, [])
|
||||
|
||||
// Combine profiles from experimental query with paginated suggestions
|
||||
const allProfiles = React.useMemo(() => {
|
||||
const fallbackProfiles =
|
||||
moreSuggestions?.pages.flatMap(page => page.actors) ?? []
|
||||
|
||||
// Dedupe by did, preferring experimental profiles
|
||||
const seen = new Set<string>()
|
||||
const combined: bsky.profile.AnyProfileView[] = []
|
||||
|
||||
for (const profile of experimentalProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of fallbackProfiles) {
|
||||
if (!seen.has(profile.did)) {
|
||||
seen.add(profile.did)
|
||||
combined.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
return combined
|
||||
}, [experimentalProfiles, moreSuggestions?.pages])
|
||||
|
||||
const filteredProfiles = React.useMemo(() => {
|
||||
return allProfiles.filter(p => !dismissedDids.has(p.did))
|
||||
}, [allProfiles, dismissedDids])
|
||||
|
||||
// Fetch more when running low
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
moderationOpts &&
|
||||
filteredProfiles.length < maxLength &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [
|
||||
filteredProfiles.length,
|
||||
maxLength,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
moderationOpts,
|
||||
])
|
||||
|
||||
return (
|
||||
<ProfileGrid
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={profiles}
|
||||
error={error}
|
||||
profiles={filteredProfiles}
|
||||
totalProfileCount={allProfiles.length}
|
||||
error={experimentalError || suggestionsError}
|
||||
viewContext="feed"
|
||||
onDismiss={onDismiss}
|
||||
dismissingDids={dismissingDids}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -240,19 +416,26 @@ export function ProfileGrid({
|
||||
isSuggestionsLoading,
|
||||
error,
|
||||
profiles,
|
||||
totalProfileCount,
|
||||
recId,
|
||||
viewContext = 'feed',
|
||||
onDismiss,
|
||||
dismissingDids,
|
||||
isVisible = true,
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
totalProfileCount?: number
|
||||
recId?: number
|
||||
error: Error | null
|
||||
dismissingDids?: Set<string>
|
||||
viewContext: 'profile' | 'profileHeader' | 'feed'
|
||||
onDismiss?: (did: string) => void
|
||||
isVisible?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const followDialogControl = useDialogControl()
|
||||
@@ -260,6 +443,7 @@ export function ProfileGrid({
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
const isProfileHeaderContext = viewContext === 'profileHeader'
|
||||
const isFeedContext = viewContext === 'feed'
|
||||
const showDismissButton = onDismiss && gate('suggested_users_dismiss')
|
||||
|
||||
const maxLength = gtMobile ? 3 : isProfileHeaderContext ? 12 : 6
|
||||
const minLength = gtMobile ? 3 : 4
|
||||
@@ -363,20 +547,9 @@ export function ProfileGrid({
|
||||
: error || !profiles.length
|
||||
? null
|
||||
: profiles.slice(0, maxLength).map((profile, index) => (
|
||||
<ProfileCard.Link
|
||||
<Animated.View
|
||||
key={profile.did}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
layout={LinearTransition.duration(DISMISS_ANIMATION_DURATION)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile &&
|
||||
@@ -385,68 +558,128 @@ export function ProfileGrid({
|
||||
a.flex_grow,
|
||||
{width: `calc(30% - ${a.gap_md.gap / 2}px)`},
|
||||
]),
|
||||
{
|
||||
opacity: dismissingDids?.has(profile.did) ? 0 : 1,
|
||||
transitionProperty: 'opacity',
|
||||
transitionDuration: `${DISMISS_ANIMATION_DURATION}ms`,
|
||||
},
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[(hovered || pressed) && t.atoms.border_contrast_high]}>
|
||||
<ProfileCard.Outer>
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pb_sm,
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
size={88}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
<ProfileCard.Link
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('suggestedUser:press', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
style={[a.flex_1]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
{showDismissButton && (
|
||||
<Button
|
||||
label={_(msg`Dismiss this suggestion`)}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
onDismiss!(profile.did)
|
||||
logEvent('suggestedUser:dismiss', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
recId,
|
||||
})
|
||||
}}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
a.p_xs,
|
||||
{top: -4, right: -4},
|
||||
]}>
|
||||
{({
|
||||
hovered: dismissHovered,
|
||||
pressed: dismissPressed,
|
||||
}) => (
|
||||
<X
|
||||
size="xs"
|
||||
fill={
|
||||
dismissHovered || dismissPressed
|
||||
? t.atoms.text.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.pb_sm,
|
||||
a.mb_auto,
|
||||
]}>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
disabledPreview
|
||||
size={88}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_center,
|
||||
a.text_xs,
|
||||
]}
|
||||
/>
|
||||
<View style={[a.flex_col, a.align_center, a.max_w_full]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.Description
|
||||
profile={profile}
|
||||
numberOfLines={2}
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_center,
|
||||
a.text_xs,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
logEvent('suggestedUser:follow', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
withIcon={false}
|
||||
style={[a.rounded_sm]}
|
||||
onFollow={() => {
|
||||
logEvent('suggestedUser:follow', {
|
||||
logContext: isFeedContext
|
||||
? 'InterstitialDiscover'
|
||||
: 'InterstitialProfile',
|
||||
location: 'Card',
|
||||
recId,
|
||||
position: index,
|
||||
suggestedDid: profile.did,
|
||||
category: null,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
</Animated.View>
|
||||
))
|
||||
|
||||
if (error || (!isLoading && profiles.length < minLength)) {
|
||||
// Use totalProfileCount (before dismissals) for minLength check on initial render.
|
||||
const profileCountForMinCheck = totalProfileCount ?? profiles.length
|
||||
if (error || (!isLoading && profileCountForMinCheck < minLength)) {
|
||||
logger.debug(`Not enough profiles to show suggested follows`)
|
||||
return null
|
||||
}
|
||||
@@ -539,7 +772,6 @@ export function ProfileGrid({
|
||||
}
|
||||
|
||||
function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
@@ -553,7 +785,6 @@ function SeeMoreSuggestedProfilesCard({onPress}: {onPress: () => void}) {
|
||||
a.gap_sm,
|
||||
a.p_md,
|
||||
a.rounded_lg,
|
||||
t.atoms.shadow_sm,
|
||||
{width: FINAL_CARD_WIDTH},
|
||||
]}>
|
||||
<ButtonIcon icon={ArrowRight} size="lg" />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {Fragment, useMemo} from 'react'
|
||||
import {Text as RNText} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
type CountryCode,
|
||||
getDefaultCountry,
|
||||
INTERNATIONAL_TELEPHONE_CODES,
|
||||
} from '#/lib/international-telephone-codes'
|
||||
@@ -23,8 +25,8 @@ export function InternationalPhoneCodeSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: string
|
||||
onChange: (value: string) => void
|
||||
value?: CountryCode
|
||||
onChange: (value: CountryCode) => void
|
||||
}) {
|
||||
const {_, i18n} = useLingui()
|
||||
const location = useGeolocation()
|
||||
@@ -62,7 +64,7 @@ export function InternationalPhoneCodeSelect({
|
||||
}, [value, items])
|
||||
|
||||
return (
|
||||
<Select.Root value={value} onValueChange={onChange}>
|
||||
<Select.Root value={value} onValueChange={onChange as (v: string) => void}>
|
||||
<Select.Trigger label={_(msg`Select telephone code`)}>
|
||||
<Select.ValueText placeholder="+..." webOverrideValue={selected}>
|
||||
{selected => (
|
||||
@@ -112,5 +114,5 @@ function Flag({unicodeFlag, svgFlag}: {unicodeFlag: string; svgFlag: any}) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
return unicodeFlag + ' '
|
||||
return <RNText style={[{lineHeight: 21}]}>{unicodeFlag + ' '}</RNText>
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Outer({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
noBottomBorder?: boolean
|
||||
headerRef?: React.MutableRefObject<View | null>
|
||||
headerRef?: React.RefObject<View | null>
|
||||
sticky?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
@@ -129,6 +129,7 @@ export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={onPressBack}
|
||||
hitSlop={HITSLOP_30}
|
||||
style={[
|
||||
@@ -183,7 +184,7 @@ export function TitleText({
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
isIOS && align === 'platform' && a.text_center,
|
||||
gtMobile && a.text_xl,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {type EmbedPlayerParams} from '#/lib/strings/embed-player'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useAutoplayDisabled} from '#/state/preferences'
|
||||
@@ -111,6 +112,15 @@ export function GifEmbed({
|
||||
playerRef.current?.toggleAsync()
|
||||
}, [])
|
||||
|
||||
let aspectRatio = 1
|
||||
if (params.dimensions) {
|
||||
aspectRatio = clamp(
|
||||
params.dimensions.width / params.dimensions.height,
|
||||
0.75,
|
||||
4,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -118,7 +128,8 @@ export function GifEmbed({
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
{aspectRatio: params.dimensions!.width / params.dimensions!.height},
|
||||
{backgroundColor: t.palette.black},
|
||||
{aspectRatio},
|
||||
style,
|
||||
]}>
|
||||
<View
|
||||
|
||||
@@ -4,7 +4,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateIcon} from '#/components/icons/ArrowRotate'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text as TypoText} from '#/components/Typography'
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
type $Typed,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {unstableCacheProfileView} from '#/state/queries/profile'
|
||||
@@ -19,6 +18,7 @@ import {useSession} from '#/state/session'
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -232,7 +232,7 @@ export function QuoteEmbed({
|
||||
viewContext?: QuoteEmbedViewContext
|
||||
}) {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const quote = React.useMemo<$Typed<AppBskyFeedDefs.PostView>>(
|
||||
const quote = useMemo<$Typed<AppBskyFeedDefs.PostView>>(
|
||||
() => ({
|
||||
...embed.view,
|
||||
$type: 'app.bsky.feed.defs#postView',
|
||||
@@ -241,18 +241,17 @@ export function QuoteEmbed({
|
||||
}),
|
||||
[embed],
|
||||
)
|
||||
const moderation = React.useMemo(() => {
|
||||
const moderation = useMemo(() => {
|
||||
return moderationOpts ? moderatePost(quote, moderationOpts) : undefined
|
||||
}, [quote, moderationOpts])
|
||||
|
||||
const t = useTheme()
|
||||
const queryClient = useQueryClient()
|
||||
const pal = usePalette('default')
|
||||
const itemUrip = new AtUri(quote.uri)
|
||||
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
|
||||
const itemTitle = `Post by ${quote.author.handle}`
|
||||
|
||||
const richText = React.useMemo(() => {
|
||||
const richText = useMemo(() => {
|
||||
if (
|
||||
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
quote.record,
|
||||
@@ -266,17 +265,26 @@ export function QuoteEmbed({
|
||||
: undefined
|
||||
}, [quote.record])
|
||||
|
||||
const onBeforePress = React.useCallback(() => {
|
||||
const onBeforePress = useCallback(() => {
|
||||
unstableCacheProfileView(queryClient, quote.author)
|
||||
onOpen?.()
|
||||
}, [queryClient, quote.author, onOpen])
|
||||
|
||||
const [hover, setHover] = React.useState(false)
|
||||
const {
|
||||
state: hover,
|
||||
onIn: onPointerEnter,
|
||||
onOut: onPointerLeave,
|
||||
} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
return (
|
||||
<View
|
||||
style={[a.mt_sm]}
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}>
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}>
|
||||
<ContentHider
|
||||
modui={moderation?.ui('contentList')}
|
||||
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
|
||||
@@ -284,13 +292,21 @@ export function QuoteEmbed({
|
||||
childContainerStyle={[a.pt_sm]}>
|
||||
{({active}) => (
|
||||
<>
|
||||
{!active && <SubtleHover hover={hover} style={[a.rounded_md]} />}
|
||||
{!active && (
|
||||
<SubtleHover
|
||||
native
|
||||
hover={hover || pressed}
|
||||
style={[a.rounded_md]}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
style={[!active && a.p_md]}
|
||||
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
|
||||
hoverStyle={t.atoms.border_contrast_high}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
onBeforePress={onBeforePress}>
|
||||
onBeforePress={onBeforePress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
<View pointerEvents="none">
|
||||
<PostMeta
|
||||
author={quote.author}
|
||||
|
||||
@@ -330,14 +330,20 @@ export function Name({
|
||||
)
|
||||
}
|
||||
|
||||
export function Handle({profile}: {profile: bsky.profile.AnyProfileView}) {
|
||||
export function Handle({
|
||||
profile,
|
||||
textStyle,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
|
||||
return (
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium, textStyle]}
|
||||
numberOfLines={1}>
|
||||
{handle}
|
||||
</Text>
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass2'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass'
|
||||
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
|
||||
@@ -574,6 +574,7 @@ function FollowProfileCardInner({
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
disabledPreview={!isWeb}
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
useRemoveMutedWordsMutation,
|
||||
useUpsertMutedWordsMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {atoms as a, useBreakpoints} from '#/alf'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded} from './icons/Times'
|
||||
|
||||
export function SearchError({
|
||||
title,
|
||||
@@ -14,7 +13,7 @@ export function SearchError({
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Layout.Content>
|
||||
@@ -27,7 +26,7 @@ export function SearchError({
|
||||
paddingVertical: 150,
|
||||
},
|
||||
]}>
|
||||
<TimesLarge_Stroke2_Corner0_Rounded width={32} fill={pal.colors.icon} />
|
||||
<XIcon width={32} style={[t.atoms.text_contrast_low]} />
|
||||
<View
|
||||
style={[
|
||||
a.align_center,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Link} from '../Link'
|
||||
import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from './country-allowlist'
|
||||
|
||||
export function FindContactsBannerNUX() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {visible, close} = useInternalState()
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.p_lg, a.border_b, t.atoms.border_contrast_low]}>
|
||||
<View style={a.w_full}>
|
||||
<Link
|
||||
to={{screen: 'FindContactsFlow'}}
|
||||
label={_(msg`Import contacts to find your friends`)}
|
||||
onPress={() => {
|
||||
logger.metric('contacts:nux:bannerPressed', {})
|
||||
}}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_xl,
|
||||
a.curve_continuous,
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={[t.palette.primary_200, t.palette.primary_50]}
|
||||
start={{x: 0, y: 0.5}}
|
||||
end={{x: 1, y: 0.5}}
|
||||
style={[
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_lg,
|
||||
a.pl_lg,
|
||||
]}>
|
||||
<Image
|
||||
source={require('../../../assets/images/find_friends_illustration_small.webp')}
|
||||
accessibilityIgnoresInvertColors
|
||||
style={[
|
||||
{height: 70, aspectRatio: 573 / 286},
|
||||
a.self_end,
|
||||
a.mt_sm,
|
||||
]}
|
||||
/>
|
||||
<View style={[a.flex_1, a.justify_center, a.py_xl, a.pr_5xl]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
{color: t.palette.primary_900},
|
||||
]}>
|
||||
<Trans>Import contacts to find your friends</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Link>
|
||||
<Button
|
||||
label={_(msg`Dismiss banner`)}
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={close}
|
||||
style={[a.absolute, {top: 14, right: 14}]}
|
||||
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}>
|
||||
<XIcon size="xs" style={[t.atoms.text_contrast_low]} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
function useInternalState() {
|
||||
const {nux} = useNux(Nux.FindContactsDismissibleBanner)
|
||||
const {mutate: save, variables} = useSaveNux()
|
||||
const hidden = !!variables
|
||||
const isFeatureEnabled = useIsFindContactsFeatureEnabledBasedOnGeolocation()
|
||||
const gate = useGate()
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (isWeb) return false
|
||||
if (hidden) return false
|
||||
if (nux && nux.completed) return false
|
||||
if (!isFeatureEnabled) return false
|
||||
if (gate('disable_settings_find_contacts')) return false
|
||||
return true
|
||||
}, [hidden, nux, isFeatureEnabled, gate])
|
||||
|
||||
const close = () => {
|
||||
save({
|
||||
id: Nux.FindContactsDismissibleBanner,
|
||||
completed: true,
|
||||
data: undefined,
|
||||
})
|
||||
logger.metric('contacts:nux:bannerDismissed', {})
|
||||
}
|
||||
|
||||
return {visible, close}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {GetContacts} from './screens/GetContacts'
|
||||
import {PhoneInput} from './screens/PhoneInput'
|
||||
import {VerifyNumber} from './screens/VerifyNumber'
|
||||
import {ViewMatches} from './screens/ViewMatches'
|
||||
import {type Action, FindContactsGoBackContext, type State} from './state'
|
||||
|
||||
export function FindContactsFlow({
|
||||
state,
|
||||
dispatch,
|
||||
onBack,
|
||||
onCancel,
|
||||
context = 'Standalone',
|
||||
}: {
|
||||
state: State
|
||||
dispatch: React.ActionDispatch<[Action]>
|
||||
onBack?: () => void
|
||||
onCancel: () => void
|
||||
context: 'Onboarding' | 'Standalone'
|
||||
}) {
|
||||
return (
|
||||
<FindContactsGoBackContext value={onBack}>
|
||||
{state.step === '1: phone input' && (
|
||||
<PhoneInput
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
context={context}
|
||||
onSkip={onCancel}
|
||||
/>
|
||||
)}
|
||||
{state.step === '2: verify number' && (
|
||||
<VerifyNumber
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
context={context}
|
||||
onSkip={onCancel}
|
||||
/>
|
||||
)}
|
||||
{state.step === '3: get contacts' && (
|
||||
<GetContacts
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
onCancel={onCancel}
|
||||
context={context}
|
||||
/>
|
||||
)}
|
||||
{state.step === '4: view matches' && (
|
||||
<ViewMatches
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
context={context}
|
||||
onNext={onCancel}
|
||||
/>
|
||||
)}
|
||||
</FindContactsGoBackContext>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function FindContactsFlow() {
|
||||
throw new Error('FindContactsFlow is not available on web')
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
|
||||
export function ContactsHeroImage() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.pl_3xl,
|
||||
a.pr_2xl,
|
||||
a.pt_4xl,
|
||||
a.pb_3xl,
|
||||
a.rounded_lg,
|
||||
{backgroundColor: t.palette.primary_50},
|
||||
]}>
|
||||
<Image
|
||||
source={require('../../../../assets/images/find_friends_illustration.webp')}
|
||||
accessibilityIgnoresInvertColors
|
||||
style={[a.w_full, {aspectRatio: 1278 / 661}]}
|
||||
alt={_(
|
||||
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {type StyleProp, type TextStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {android, atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/CircleInfo'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function InviteInfo({
|
||||
iconStyle,
|
||||
iconOffset = 0,
|
||||
}: {
|
||||
iconStyle: StyleProp<TextStyle>
|
||||
/**
|
||||
* Adjust the vertical position of the icon via `translateY`
|
||||
*
|
||||
* @platform android
|
||||
*/
|
||||
iconOffset?: number
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const style = [a.text_md, a.leading_snug, a.mt_xs]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Learn more about how inviting friends works`)}
|
||||
onPress={control.open}
|
||||
hitSlop={HITSLOP_20}
|
||||
style={android({transform: [{translateY: iconOffset}]})}>
|
||||
<InfoIcon style={iconStyle} size="sm" />
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner label={_(msg`Invite Friends`)}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Invite Friends</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
<Trans>
|
||||
It looks like some of your contacts have not tried to find you
|
||||
here yet. You can personally invite them by customizing a draft
|
||||
message we will provide.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={[style, a.font_medium, a.mt_lg]}>
|
||||
<Trans>How it works:</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>Choose who to invite</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>Personalize the message</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>Send the message from your phone</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
•{' '}
|
||||
<Trans>
|
||||
We don't store your friends' phone numbers or send any messages
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
label={_(msg`Done`)}
|
||||
onPress={() => control.close()}
|
||||
size="large"
|
||||
color="primary"
|
||||
style={[a.mt_2xl]}>
|
||||
<ButtonText>
|
||||
<Trans>Done</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
TextInput,
|
||||
type TextInputSelectionChangeEvent,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {atoms as a, ios, platform, useTheme} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function OTPInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
ref,
|
||||
numberOfDigits = 6,
|
||||
onComplete,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (text: string) => void
|
||||
ref?: React.Ref<TextInput>
|
||||
numberOfDigits?: number
|
||||
onComplete?: (code: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const innerRef = useRef<TextInput>(null)
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [selection, setSelection] = useState({start: 0, end: 0})
|
||||
|
||||
const onChangeText = (text: string) => {
|
||||
// only numbers
|
||||
text = text.replace(/[^0-9]/g, '')
|
||||
text = text.slice(0, numberOfDigits)
|
||||
onChange(text)
|
||||
if (text.length === numberOfDigits) {
|
||||
onComplete?.(text)
|
||||
innerRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectionChange = (evt: TextInputSelectionChangeEvent) => {
|
||||
setSelection(evt.nativeEvent.selection)
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityLabel={_(msg`Focus code input`)}
|
||||
accessibilityRole="button"
|
||||
accessibilityHint=""
|
||||
style={[a.w_full, a.relative]}
|
||||
onPress={() => {
|
||||
innerRef.current?.focus()
|
||||
innerRef.current?.clear()
|
||||
}}>
|
||||
<View style={[a.w_full, a.flex_row, a.gap_sm]}>
|
||||
{[...value.padEnd(numberOfDigits, ' ')].map((digit, index) => {
|
||||
const selected = focused
|
||||
? selection.start === selection.end
|
||||
? selection.start === index
|
||||
: index >= selection.start && index < selection.end
|
||||
: false
|
||||
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
height: 64,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
borderColor: selected
|
||||
? t.palette.primary_500
|
||||
: t.atoms.bg_contrast_50.backgroundColor,
|
||||
},
|
||||
]}>
|
||||
<Text style={[a.text_2xl, a.text_center, a.font_bold]}>
|
||||
{digit}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
<TextInput
|
||||
// SMS autofill is borked on iOS if you open the keyboard immediately -sfn
|
||||
onLayout={ios(() => setTimeout(() => innerRef.current?.focus(), 100))}
|
||||
autoFocus={isAndroid}
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint=""
|
||||
accessibilityRole="text"
|
||||
ref={mergeRefs(ref ? [ref, innerRef] : [innerRef])}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onSelectionChange={onSelectionChange}
|
||||
keyboardAppearance={t.scheme}
|
||||
inputMode="numeric"
|
||||
keyboardType="number-pad"
|
||||
textContentType="oneTimeCode"
|
||||
autoComplete={platform({
|
||||
android: 'sms-otp',
|
||||
ios: 'one-time-code',
|
||||
})}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
maxLength={numberOfDigits}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
// roughly vibe align the characters
|
||||
// with the visible ones so that
|
||||
// moving the caret via long press
|
||||
// still kinda sorta works
|
||||
{
|
||||
fontVariant: ['tabular-nums'],
|
||||
textAlignVertical: 'center',
|
||||
letterSpacing: 24,
|
||||
fontSize: 60,
|
||||
paddingLeft: 6,
|
||||
},
|
||||
platform({
|
||||
// completely transparent inputs on iOS cannot be pasted into
|
||||
ios: {opacity: 0.02, color: 'transparent'},
|
||||
android: {opacity: 0},
|
||||
}),
|
||||
]}
|
||||
caretHidden={isIOS}
|
||||
clearTextOnFocus
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {type AppBskyContactDefs} from '@atproto/api'
|
||||
|
||||
import {type CountryCode} from '#/lib/international-telephone-codes'
|
||||
import {normalizePhoneNumber} from './phone-number'
|
||||
import {type Contact, type Match} from './state'
|
||||
|
||||
/**
|
||||
* Filters out contacts that do not have any associated phone numbers,
|
||||
* as well as businesses
|
||||
*/
|
||||
export function contactsWithPhoneNumbersOnly(contacts: Contact[]) {
|
||||
return contacts.filter(
|
||||
contact =>
|
||||
contact.phoneNumbers &&
|
||||
contact.phoneNumbers.length > 0 &&
|
||||
contact.contactType !== 'company',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the raw contact book and returns a plain list of numbers in E.164 format, along
|
||||
* with a mapping to retrieve the contact ID when we get the results back.
|
||||
*
|
||||
* `countryCode` is used as a fallback for local numbers that don't have a country code associated with them.
|
||||
* I'm making the assumption that most local numbers in someone's phone book will be the same as theirs.
|
||||
*/
|
||||
export function normalizeContactBook(
|
||||
contacts: Contact[],
|
||||
countryCode: CountryCode,
|
||||
ownNumber: string,
|
||||
): {
|
||||
phoneNumbers: string[]
|
||||
indexToContactId: Map<number, Contact['id']>
|
||||
} {
|
||||
const phoneNumbers: string[] = []
|
||||
const indexToContactId = new Map<number, Contact['id']>()
|
||||
|
||||
for (const contact of contacts) {
|
||||
for (const number of contact.phoneNumbers ?? []) {
|
||||
let rawNumber: string
|
||||
|
||||
if (number.number) {
|
||||
rawNumber = number.number
|
||||
} else if (number.digits) {
|
||||
rawNumber = number.digits
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = normalizePhoneNumber(
|
||||
rawNumber,
|
||||
number.countryCode,
|
||||
countryCode,
|
||||
)
|
||||
if (normalized === null) continue
|
||||
|
||||
// skip if it's your own number
|
||||
if (normalized === ownNumber) continue
|
||||
|
||||
phoneNumbers.push(normalized)
|
||||
indexToContactId.set(phoneNumbers.length - 1, contact.id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phoneNumbers,
|
||||
indexToContactId,
|
||||
}
|
||||
}
|
||||
|
||||
export function filterMatchedNumbers(
|
||||
contacts: Contact[],
|
||||
results: AppBskyContactDefs.MatchAndContactIndex[],
|
||||
mapping: Map<number, Contact['id']>,
|
||||
) {
|
||||
const filteredIds = new Set<Contact['id']>()
|
||||
|
||||
for (const result of results) {
|
||||
const id = mapping.get(result.contactIndex)
|
||||
if (id !== undefined) {
|
||||
filteredIds.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
return contacts.filter(contact => !filteredIds.has(contact.id))
|
||||
}
|
||||
|
||||
export function getMatchedContacts(
|
||||
contacts: Contact[],
|
||||
results: AppBskyContactDefs.MatchAndContactIndex[],
|
||||
mapping: Map<number, Contact['id']>,
|
||||
): Array<Match> {
|
||||
const contactsById = new Map(contacts.map(c => [c.id, c]))
|
||||
|
||||
return results.map(result => {
|
||||
const id = mapping.get(result.contactIndex)
|
||||
const contact = id !== undefined ? contactsById.get(id) : undefined
|
||||
return {profile: result.match, contact}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {type CountryCode} from '#/lib/international-telephone-codes'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
const FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST = [
|
||||
'US',
|
||||
'GB',
|
||||
'JP',
|
||||
'CA',
|
||||
'DE',
|
||||
'FR',
|
||||
'ES',
|
||||
'BR',
|
||||
'KR',
|
||||
'NL',
|
||||
'AU',
|
||||
'SE',
|
||||
'IT',
|
||||
] satisfies CountryCode[] as string[]
|
||||
|
||||
export function isFindContactsFeatureEnabled(countryCode?: string): boolean {
|
||||
if (IS_DEV) return true
|
||||
|
||||
/*
|
||||
* This should never happen unless geolocation fails entirely. In that
|
||||
* case, let the user try, since it should work as long as they have a
|
||||
* phone number from one of the allow-listed countries.
|
||||
*/
|
||||
if (!countryCode) return true
|
||||
|
||||
return FIND_CONTACTS_FEATURE_COUNTRY_ALLOWLIST.includes(
|
||||
countryCode.toUpperCase(),
|
||||
)
|
||||
}
|
||||
|
||||
export function useIsFindContactsFeatureEnabledBasedOnGeolocation() {
|
||||
const location = useGeolocation()
|
||||
return isFindContactsFeatureEnabled(location.countryCode)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {t} from '@lingui/macro'
|
||||
import {
|
||||
isSupportedCountry,
|
||||
ParseError,
|
||||
parsePhoneNumber,
|
||||
parsePhoneNumberWithError,
|
||||
type PhoneNumber,
|
||||
} from 'libphonenumber-js/max'
|
||||
|
||||
import {type CountryCode} from '#/lib/international-telephone-codes'
|
||||
|
||||
/**
|
||||
* Intended for after the user has finished inputting their phone number.
|
||||
*/
|
||||
export function processPhoneNumber(
|
||||
number: string,
|
||||
country: CountryCode,
|
||||
):
|
||||
| {
|
||||
valid: true
|
||||
formatted: string
|
||||
countryCode: CountryCode
|
||||
}
|
||||
| {
|
||||
valid: false
|
||||
reason?: string
|
||||
} {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumberWithError(number, {
|
||||
defaultCountry: country,
|
||||
})
|
||||
if (!phoneNumber.isValid()) {
|
||||
return {valid: false, reason: t`Invalid phone number`}
|
||||
}
|
||||
const type = phoneNumber.getType()
|
||||
if (
|
||||
type !== 'MOBILE' &&
|
||||
type !== 'FIXED_LINE_OR_MOBILE' &&
|
||||
type !== 'PERSONAL_NUMBER'
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: t`Number should be a mobile number`,
|
||||
}
|
||||
}
|
||||
let countryCode = country
|
||||
if (phoneNumber.country && phoneNumber.country !== country) {
|
||||
if (phoneNumber.country === 'AC' || phoneNumber.country === 'TA') {
|
||||
countryCode = 'SH'
|
||||
} else {
|
||||
countryCode = phoneNumber.country
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
formatted: formatE164lWithoutCountryCode(phoneNumber),
|
||||
countryCode,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ParseError) {
|
||||
return {valid: false, reason: error.message}
|
||||
} else {
|
||||
return {valid: false}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a phone number as the international format with the prefix
|
||||
* removed.
|
||||
*/
|
||||
function formatE164lWithoutCountryCode(phoneNumber: PhoneNumber) {
|
||||
const intl = phoneNumber.format('E.164')
|
||||
const prefix = '+' + phoneNumber.countryCallingCode
|
||||
return intl.replace(prefix, '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a country code and a prefix-less phone number and constructs a full phone number.
|
||||
*
|
||||
* Does not have nice error handling - if you're unsure if the number is valid, use
|
||||
* `processPhoneNumber` instead
|
||||
*/
|
||||
export function constructFullPhoneNumber(
|
||||
countryCode: CountryCode,
|
||||
phoneNumber: string,
|
||||
) {
|
||||
const result = parsePhoneNumber(phoneNumber, {defaultCountry: countryCode})
|
||||
if (!result.isValid())
|
||||
throw new Error('Invalid phone number passed to constructFullPhoneNumber')
|
||||
return result.format('E.164')
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a phone number and applies human-readable formatting. Do not sent to the API - they
|
||||
* expect E.164 format.
|
||||
*/
|
||||
export function prettyPhoneNumber(phoneNumber: string) {
|
||||
const result = parsePhoneNumber(phoneNumber)
|
||||
return result.formatNational()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse a phone number from a string, and returns the country code
|
||||
* and the rest of the number if possible. If the number is invalid, returns undefined.
|
||||
*/
|
||||
export function getCountryCodeFromPastedNumber(
|
||||
text: string,
|
||||
): {countryCode: CountryCode; rest: string} | undefined {
|
||||
try {
|
||||
const phoneNumber = parsePhoneNumber(text)
|
||||
if (!phoneNumber.isValid()) {
|
||||
return undefined
|
||||
}
|
||||
const countryCode = phoneNumber.country
|
||||
// we don't have AC and TA in our dropdown - see `#/lib/international-telephone-codes`
|
||||
if (countryCode && countryCode !== 'AC' && countryCode !== 'TA') {
|
||||
return {
|
||||
countryCode,
|
||||
rest: formatE164lWithoutCountryCode(phoneNumber),
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
} catch (error) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a phone number into E.164 format
|
||||
*/
|
||||
export function normalizePhoneNumber(
|
||||
rawNumber: string,
|
||||
countryCode: string | undefined,
|
||||
fallbackCountryCode: CountryCode,
|
||||
): string | null {
|
||||
try {
|
||||
const result = parsePhoneNumber(rawNumber, {
|
||||
defaultCountry:
|
||||
countryCode && isSupportedCountry(countryCode)
|
||||
? countryCode
|
||||
: fallbackCountryCode,
|
||||
})
|
||||
|
||||
if (!result.isValid()) return null
|
||||
|
||||
const type = result.getType()
|
||||
if (
|
||||
type !== 'MOBILE' &&
|
||||
type !== 'FIXED_LINE_OR_MOBILE' &&
|
||||
type !== 'PERSONAL_NUMBER'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.format('E.164')
|
||||
} catch (error) {
|
||||
console.log('Failed to normalize phone number:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import {useContext} from 'react'
|
||||
import {Alert, View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import * as Contacts from 'expo-contacts'
|
||||
import type AtpAgent from '@atproto/api'
|
||||
import {
|
||||
type AppBskyActorProfile,
|
||||
AppBskyContactImportContacts,
|
||||
type Un$Typed,
|
||||
} from '@atproto/api'
|
||||
import {msg, t, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {uploadBlob} from '#/lib/api'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {findContactsStatusQueryKey} from '#/state/queries/find-contacts'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {
|
||||
Context as OnboardingContext,
|
||||
type OnboardingAction,
|
||||
type OnboardingState,
|
||||
} from '#/screens/Onboarding/state'
|
||||
import {atoms as a, ios, tokens, useGutters} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
contactsWithPhoneNumbersOnly,
|
||||
filterMatchedNumbers,
|
||||
getMatchedContacts,
|
||||
normalizeContactBook,
|
||||
} from '../contacts'
|
||||
import {constructFullPhoneNumber} from '../phone-number'
|
||||
import {type Action, type State} from '../state'
|
||||
|
||||
const MAX_UPLOAD_COUNT = 1000
|
||||
|
||||
export function GetContacts({
|
||||
state,
|
||||
dispatch,
|
||||
onCancel,
|
||||
context,
|
||||
}: {
|
||||
state: Extract<State, {step: '3: get contacts'}>
|
||||
dispatch: React.ActionDispatch<[Action]>
|
||||
onCancel: () => void
|
||||
context: 'Onboarding' | 'Standalone'
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const insets = useSafeAreaInsets()
|
||||
const gutters = useGutters([0, 'wide'])
|
||||
const queryClient = useQueryClient()
|
||||
const maybeOnboardingContext = useContext(OnboardingContext)
|
||||
|
||||
const {mutate: uploadContacts, isPending: isUploadPending} = useMutation({
|
||||
mutationFn: async (contacts: Contacts.ExistingContact[]) => {
|
||||
/**
|
||||
* `importContacts` triggers a notification for the people you match with,
|
||||
* however we prevent notifications coming from users without profiles.
|
||||
* If you're using this as the onboarding flow, we need to create a profile
|
||||
* record before this.
|
||||
*
|
||||
* When you finish onboarding, we'll upsert again - bit wasteful but fine.
|
||||
*/
|
||||
if (context === 'Onboarding' && maybeOnboardingContext) {
|
||||
try {
|
||||
await createProfileRecord(agent, maybeOnboardingContext)
|
||||
} catch (error) {
|
||||
logger.debug('Error creating profile record:', {safeMessage: error})
|
||||
}
|
||||
}
|
||||
|
||||
const {phoneNumbers, indexToContactId} = normalizeContactBook(
|
||||
contacts,
|
||||
state.phoneCountryCode,
|
||||
constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
|
||||
)
|
||||
|
||||
if (phoneNumbers.length > 0) {
|
||||
const res = await agent.app.bsky.contact.importContacts({
|
||||
token: state.token,
|
||||
contacts: phoneNumbers.slice(0, MAX_UPLOAD_COUNT),
|
||||
})
|
||||
|
||||
return {
|
||||
matches: res.data.matchesAndContactIndexes,
|
||||
indexToContactId,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
matches: [],
|
||||
indexToContactId,
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (result, contacts) => {
|
||||
if (context === 'Onboarding') {
|
||||
logger.metric('onboarding:contacts:contactsShared', {})
|
||||
}
|
||||
if (result.matches.length > 0) {
|
||||
logger.metric('contacts:import:success', {
|
||||
contactCount: contacts.length,
|
||||
matchCount: result.matches.length,
|
||||
entryPoint: context,
|
||||
})
|
||||
} else {
|
||||
logger.metric('contacts:import:failure', {
|
||||
reason: 'noValidNumbers',
|
||||
entryPoint: context,
|
||||
})
|
||||
}
|
||||
dispatch({
|
||||
type: 'SYNC_CONTACTS_SUCCESS',
|
||||
payload: {
|
||||
matches: getMatchedContacts(
|
||||
contacts,
|
||||
result.matches,
|
||||
result.indexToContactId,
|
||||
),
|
||||
contacts: filterMatchedNumbers(
|
||||
contacts,
|
||||
result.matches,
|
||||
result.indexToContactId,
|
||||
),
|
||||
},
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: findContactsStatusQueryKey,
|
||||
})
|
||||
},
|
||||
onError: err => {
|
||||
logger.metric('contacts:import:failure', {
|
||||
reason: isNetworkError(err) ? 'networkError' : 'unknown',
|
||||
entryPoint: context,
|
||||
})
|
||||
if (isNetworkError(err)) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`There was a problem with your internet connection, please try again`,
|
||||
),
|
||||
{type: 'error'},
|
||||
)
|
||||
} else if (
|
||||
err instanceof AppBskyContactImportContacts.TooManyContactsError
|
||||
) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Too many contacts - you've exceeded the number of contacts you can import to find your friends`,
|
||||
),
|
||||
{type: 'error'},
|
||||
)
|
||||
} else if (
|
||||
err instanceof AppBskyContactImportContacts.InvalidTokenError
|
||||
) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Could not upload contacts. You need to re-verify your phone number to proceed`,
|
||||
),
|
||||
{type: 'error'},
|
||||
)
|
||||
} else {
|
||||
logger.error('Error uploading contacts', {safeMessage: err})
|
||||
Toast.show(_(msg`Could not upload contacts. ${cleanError(err)}`), {
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: getContacts, isPending: isGetContactsPending} = useMutation({
|
||||
mutationFn: async () => {
|
||||
let permissions = await Contacts.getPermissionsAsync()
|
||||
|
||||
if (!permissions.granted && permissions.canAskAgain) {
|
||||
permissions = await Contacts.requestPermissionsAsync()
|
||||
}
|
||||
|
||||
logger.metric('contacts:permission:request', {
|
||||
status: permissions.granted ? 'granted' : 'denied',
|
||||
accessLevelIOS: ios(permissions.accessPrivileges),
|
||||
})
|
||||
|
||||
if (!permissions.granted) {
|
||||
throw new PermissionDeniedError()
|
||||
}
|
||||
|
||||
const contacts = await Contacts.getContactsAsync({
|
||||
fields: [
|
||||
Contacts.Fields.FirstName,
|
||||
Contacts.Fields.LastName,
|
||||
Contacts.Fields.PhoneNumbers,
|
||||
Contacts.Fields.Image,
|
||||
],
|
||||
})
|
||||
|
||||
return contactsWithPhoneNumbersOnly(contacts.data)
|
||||
},
|
||||
onSuccess: contacts => {
|
||||
dispatch({
|
||||
type: 'GET_CONTACTS_SUCCESS',
|
||||
payload: {contacts},
|
||||
})
|
||||
uploadContacts(contacts)
|
||||
},
|
||||
onError: err => {
|
||||
if (err instanceof PermissionDeniedError) {
|
||||
showPermissionDeniedAlert()
|
||||
} else {
|
||||
logger.error('Error getting contacts', {safeMessage: err})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const isPending = isUploadPending || isGetContactsPending
|
||||
|
||||
const style = [a.text_md, a.leading_snug, a.mt_md]
|
||||
|
||||
return (
|
||||
<View style={[a.h_full]}>
|
||||
<Layout.Content
|
||||
contentContainerStyle={[gutters, a.flex_1, a.pt_xl]}
|
||||
bounces={false}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
<Trans>Share your contacts to find friends</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
<Trans>
|
||||
Bluesky helps friends find each other by creating an encoded digital
|
||||
fingerprint, called a "hash", and then looking for matching hashes.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>We never keep plain phone numbers</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>We delete hashes after matches are made</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>We only suggest follows if both people consent</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>You can always opt out and delete your data</Trans>
|
||||
</Text>
|
||||
<Text style={[style, a.mt_lg]}>
|
||||
<Trans>
|
||||
We apply the highest privacy standards, and never share or sell your
|
||||
contact information.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Layout.Content>
|
||||
<View
|
||||
style={[
|
||||
gutters,
|
||||
a.pt_xs,
|
||||
{paddingBottom: Math.max(insets.bottom, tokens.space.xl)},
|
||||
a.gap_md,
|
||||
]}>
|
||||
<Text style={[a.text_sm, a.pb_xs]}>
|
||||
<Trans>
|
||||
I consent to Bluesky using my contacts for mutual friend discovery
|
||||
and to retain hashed data for matching until I opt out.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
label={_(msg`Find my friends`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => getContacts()}
|
||||
disabled={isPending}>
|
||||
{isUploadPending ? (
|
||||
<>
|
||||
<ButtonText>
|
||||
<Trans>Finding friends...</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={Loader} />
|
||||
</>
|
||||
) : (
|
||||
<ButtonText>
|
||||
<Trans>Find my friends</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Cancel`)}
|
||||
size="large"
|
||||
color="secondary"
|
||||
onPress={onCancel}>
|
||||
<ButtonText>
|
||||
<Trans>Cancel</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
class PermissionDeniedError extends Error {
|
||||
constructor() {
|
||||
super('Permission denied')
|
||||
}
|
||||
}
|
||||
|
||||
function showPermissionDeniedAlert() {
|
||||
Alert.alert(
|
||||
t`You've denied access to your contacts`,
|
||||
t`You'll need to go to the System Settings for Bluesky and give permission if you want to use this feature.`,
|
||||
[
|
||||
{
|
||||
text: t`OK`,
|
||||
style: 'default',
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied from `#/screens/Onboarding/StepFinished/index.tsx`
|
||||
*/
|
||||
async function createProfileRecord(
|
||||
agent: AtpAgent,
|
||||
onboardingContext: {
|
||||
state: OnboardingState
|
||||
dispatch: React.Dispatch<OnboardingAction>
|
||||
},
|
||||
) {
|
||||
const profileStepResults = onboardingContext.state.profileStepResults
|
||||
const {imageUri, imageMime} = profileStepResults
|
||||
const blobPromise =
|
||||
imageUri && imageMime ? uploadBlob(agent, imageUri, imageMime) : undefined
|
||||
|
||||
await agent.upsertProfile(async existing => {
|
||||
let next: Un$Typed<AppBskyActorProfile.Record> = existing ?? {}
|
||||
|
||||
if (blobPromise) {
|
||||
const res = await blobPromise
|
||||
if (res.data.blob) {
|
||||
next.avatar = res.data.blob
|
||||
}
|
||||
}
|
||||
|
||||
next.displayName = ''
|
||||
|
||||
next.createdAt = new Date().toISOString()
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import {useState} from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {AppBskyContactStartPhoneVerification} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {urls} from '#/lib/constants'
|
||||
import {
|
||||
type CountryCode,
|
||||
getDefaultCountry,
|
||||
} from '#/lib/international-telephone-codes'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
|
||||
import {
|
||||
android,
|
||||
atoms as a,
|
||||
platform,
|
||||
tokens,
|
||||
useGutters,
|
||||
useTheme,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {InternationalPhoneCodeSelect} from '#/components/InternationalPhoneCodeSelect'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
import {isFindContactsFeatureEnabled} from '../country-allowlist'
|
||||
import {
|
||||
constructFullPhoneNumber,
|
||||
getCountryCodeFromPastedNumber,
|
||||
processPhoneNumber,
|
||||
} from '../phone-number'
|
||||
import {type Action, type State, useOnPressBackButton} from '../state'
|
||||
|
||||
export function PhoneInput({
|
||||
state,
|
||||
dispatch,
|
||||
context,
|
||||
onSkip,
|
||||
}: {
|
||||
state: Extract<State, {step: '1: phone input'}>
|
||||
dispatch: React.ActionDispatch<[Action]>
|
||||
context: 'Onboarding' | 'Standalone'
|
||||
onSkip: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const agent = useAgent()
|
||||
const location = useGeolocation()
|
||||
const [countryCode, setCountryCode] = useState(
|
||||
() => state.phoneCountryCode ?? getDefaultCountry(location),
|
||||
)
|
||||
const [phoneNumber, setPhoneNumber] = useState(state.phoneNumber ?? '')
|
||||
const gutters = useGutters([0, 'wide'])
|
||||
const insets = useSafeAreaInsets()
|
||||
// for API/generic errors
|
||||
const [error, setError] = useState('')
|
||||
// for issues with parsing the number
|
||||
const [formatError, setFormatError] = useState('')
|
||||
|
||||
const {mutate: submit, isPending} = useMutation({
|
||||
mutationFn: async ({
|
||||
phoneCountryCode,
|
||||
phoneNumber,
|
||||
}: {
|
||||
phoneCountryCode: CountryCode
|
||||
phoneNumber: string
|
||||
}) => {
|
||||
// sends a onetime code to the user's phone number
|
||||
await agent.app.bsky.contact.startPhoneVerification({
|
||||
phone: constructFullPhoneNumber(phoneCountryCode, phoneNumber),
|
||||
})
|
||||
},
|
||||
onSuccess: (_data, {phoneCountryCode, phoneNumber}) => {
|
||||
dispatch({
|
||||
type: 'SUBMIT_PHONE_NUMBER',
|
||||
payload: {phoneCountryCode, phoneNumber},
|
||||
})
|
||||
|
||||
logger.metric('contacts:phone:phoneEntered', {entryPoint: context})
|
||||
},
|
||||
onMutate: () => {
|
||||
Keyboard.dismiss()
|
||||
setError('')
|
||||
setFormatError('')
|
||||
},
|
||||
onError: err => {
|
||||
if (isNetworkError(err)) {
|
||||
setError(
|
||||
_(
|
||||
msg`A network error occurred. Please check your internet connection`,
|
||||
),
|
||||
)
|
||||
} else if (
|
||||
err instanceof
|
||||
AppBskyContactStartPhoneVerification.RateLimitExceededError
|
||||
) {
|
||||
setError(_(msg`Rate limit exceeded. Please try again later.`))
|
||||
} else if (
|
||||
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
|
||||
) {
|
||||
setError(
|
||||
_(
|
||||
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
logger.error('Verify phone number failed', {safeMessage: err})
|
||||
setError(_(msg`An error occurred. ${cleanError(err)}`))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const isFeatureEnabled = isFindContactsFeatureEnabled(countryCode)
|
||||
|
||||
const onSubmitNumber = () => {
|
||||
if (!isFeatureEnabled) return
|
||||
if (!phoneNumber) return
|
||||
const result = processPhoneNumber(phoneNumber, countryCode)
|
||||
if (result.valid) {
|
||||
setPhoneNumber(result.formatted)
|
||||
setCountryCode(result.countryCode)
|
||||
|
||||
if (!isFindContactsFeatureEnabled(result.countryCode)) return
|
||||
|
||||
submit({
|
||||
phoneCountryCode: result.countryCode,
|
||||
phoneNumber: result.formatted,
|
||||
})
|
||||
} else {
|
||||
setFormatError(result.reason ?? _(msg`Invalid phone number`))
|
||||
}
|
||||
}
|
||||
|
||||
const paddingBottom = Math.max(insets.bottom, tokens.space.xl)
|
||||
|
||||
const onPressBack = useOnPressBackButton()
|
||||
|
||||
return (
|
||||
<View style={[a.h_full]}>
|
||||
<Layout.Header.Outer noBottomBorder>
|
||||
<Layout.Header.BackButton onPress={onPressBack} />
|
||||
<Layout.Header.Content />
|
||||
{context === 'Onboarding' ? (
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
label={_(msg`Skip contact sharing and continue to the app`)}
|
||||
onPress={onSkip}>
|
||||
<ButtonText>
|
||||
<Trans>Skip</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : (
|
||||
<Layout.Header.Slot />
|
||||
)}
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content
|
||||
contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
|
||||
keyboardShouldPersistTaps="handled">
|
||||
{context === 'Onboarding' && <OnboardingPosition />}
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
<Trans>Verify phone number</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
a.mt_sm,
|
||||
]}>
|
||||
<Trans>
|
||||
We need to verify your number before we can look for your friends. A
|
||||
verification code will be sent to this number.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.mt_2xl]}>
|
||||
<TextField.LabelText>
|
||||
<Trans>Phone number</Trans>
|
||||
</TextField.LabelText>
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
<View>
|
||||
<InternationalPhoneCodeSelect
|
||||
value={countryCode}
|
||||
onChange={value => setCountryCode(value)}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.flex_1]}>
|
||||
<TextField.Root isInvalid={!!formatError || !isFeatureEnabled}>
|
||||
<TextField.Input
|
||||
label={_(msg`Phone number`)}
|
||||
value={phoneNumber}
|
||||
onChangeText={text => {
|
||||
if (formatError) setFormatError('')
|
||||
if (Math.abs(text.length - phoneNumber.length) > 1) {
|
||||
// possibly pasted/autocompleted? auto-switch
|
||||
// country code if possible
|
||||
const result = getCountryCodeFromPastedNumber(text)
|
||||
if (result) {
|
||||
setCountryCode(result.countryCode)
|
||||
setPhoneNumber(result.rest)
|
||||
return
|
||||
}
|
||||
}
|
||||
setPhoneNumber(text)
|
||||
}}
|
||||
placeholder={null}
|
||||
keyboardType={platform({
|
||||
ios: 'number-pad',
|
||||
android: 'phone-pad',
|
||||
})}
|
||||
autoComplete="tel"
|
||||
returnKeyType={android('next')}
|
||||
onSubmitEditing={onSubmitNumber}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!isFeatureEnabled && (
|
||||
<ErrorText>
|
||||
<Trans>
|
||||
Support for this feature in your country has not been enabled yet!
|
||||
Please check back later.
|
||||
</Trans>
|
||||
</ErrorText>
|
||||
)}
|
||||
{error && <ErrorText>{error}</ErrorText>}
|
||||
{formatError && <ErrorText>{formatError}</ErrorText>}
|
||||
|
||||
<View style={[a.mt_auto, a.py_xl]}>
|
||||
<LegalDisclaimer />
|
||||
</View>
|
||||
</Layout.Content>
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={insets.top - paddingBottom + tokens.space.xl}>
|
||||
<View style={[gutters, {paddingBottom}]}>
|
||||
<Button
|
||||
disabled={!phoneNumber || isPending}
|
||||
label={_(msg`Send code`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={onSubmitNumber}>
|
||||
<ButtonText>
|
||||
<Trans>Send code</Trans>
|
||||
</ButtonText>
|
||||
{isPending && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function LegalDisclaimer() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const style = [a.text_xs, t.atoms.text_contrast_medium, a.leading_snug]
|
||||
|
||||
return (
|
||||
<View style={[a.gap_xs]}>
|
||||
<Text style={[style, a.font_medium]}>
|
||||
<Trans>How we use your number:</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
•{' '}
|
||||
<Trans>Sent to our phone number verification provider Plivo</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
• <Trans>Deleted by Plivo after verification</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
•{' '}
|
||||
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
|
||||
</Text>
|
||||
<Text style={style}>
|
||||
•{' '}
|
||||
<Trans>Stored as part of a secure code for matching with others</Trans>
|
||||
</Text>
|
||||
<Text style={[style, a.mt_xs]}>
|
||||
<Trans>
|
||||
By continuing, you consent to this use. You may change your mind any
|
||||
time by visiting settings.{' '}
|
||||
<InlineLinkText
|
||||
to={urls.website.support.findFriendsPrivacyPolicy}
|
||||
label={_(
|
||||
msg({
|
||||
message: `Learn more about importing contacts`,
|
||||
context: `english-only-resource`,
|
||||
}),
|
||||
)}
|
||||
style={[a.text_xs, a.leading_snug]}>
|
||||
<Trans context="english-only-resource">Learn more</Trans>
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorText({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
{color: t.palette.negative_500},
|
||||
a.leading_snug,
|
||||
a.mt_md,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import {useEffect, useMemo, useState} from 'react'
|
||||
import {Text as NestedText, View} from 'react-native'
|
||||
import {
|
||||
AppBskyContactStartPhoneVerification,
|
||||
AppBskyContactVerifyPhone,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
|
||||
import {atoms as a, useGutters, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {OTPInput} from '../components/OTPInput'
|
||||
import {constructFullPhoneNumber, prettyPhoneNumber} from '../phone-number'
|
||||
import {type Action, type State, useOnPressBackButton} from '../state'
|
||||
|
||||
export function VerifyNumber({
|
||||
state,
|
||||
dispatch,
|
||||
context,
|
||||
onSkip,
|
||||
}: {
|
||||
state: Extract<State, {step: '2: verify number'}>
|
||||
dispatch: React.ActionDispatch<[Action]>
|
||||
context: 'Onboarding' | 'Standalone'
|
||||
onSkip: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const gutters = useGutters([0, 'wide'])
|
||||
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [error, setError] = useState<{
|
||||
retryable: boolean
|
||||
isResendError: boolean
|
||||
message: string
|
||||
} | null>(null)
|
||||
|
||||
const [prevOtpCode, setPrevOtpCode] = useState(otpCode)
|
||||
if (otpCode !== prevOtpCode) {
|
||||
setPrevOtpCode(otpCode)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const phone = useMemo(
|
||||
() => constructFullPhoneNumber(state.phoneCountryCode, state.phoneNumber),
|
||||
[state.phoneCountryCode, state.phoneNumber],
|
||||
)
|
||||
|
||||
const prettyNumber = useMemo(() => prettyPhoneNumber(phone), [phone])
|
||||
|
||||
const {
|
||||
mutate: verifyNumber,
|
||||
isPending,
|
||||
isSuccess,
|
||||
} = useMutation({
|
||||
mutationFn: async (code: string) => {
|
||||
const res = await agent.app.bsky.contact.verifyPhone({code, phone})
|
||||
return res.data.token
|
||||
},
|
||||
onSuccess: async token => {
|
||||
// let the success state show for a moment
|
||||
setTimeout(() => {
|
||||
dispatch({
|
||||
type: 'VERIFY_PHONE_NUMBER_SUCCESS',
|
||||
payload: {
|
||||
token,
|
||||
},
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
logger.metric('contacts:phone:phoneVerified', {entryPoint: context})
|
||||
},
|
||||
onMutate: () => setError(null),
|
||||
onError: err => {
|
||||
setOtpCode('')
|
||||
if (isNetworkError(err)) {
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: false,
|
||||
message: _(
|
||||
msg`A network error occurred. Please check your internet connection.`,
|
||||
),
|
||||
})
|
||||
} else if (err instanceof AppBskyContactVerifyPhone.InvalidCodeError) {
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: true,
|
||||
message: _(msg`This code is invalid. Resend to get a new code.`),
|
||||
})
|
||||
} else if (err instanceof AppBskyContactVerifyPhone.InvalidPhoneError) {
|
||||
setError({
|
||||
retryable: false,
|
||||
isResendError: false,
|
||||
message: _(
|
||||
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
|
||||
),
|
||||
})
|
||||
} else if (
|
||||
err instanceof AppBskyContactVerifyPhone.RateLimitExceededError
|
||||
) {
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: false,
|
||||
message: _(
|
||||
msg`Too many attempts. Please wait a few minutes and try again.`,
|
||||
),
|
||||
})
|
||||
} else {
|
||||
logger.error('Verify phone number failed', {safeMessage: err})
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: false,
|
||||
message: _(msg`An error occurred. ${cleanError(err)}`),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const {mutate: resendCode, isPending: isResendingCode} = useMutation({
|
||||
mutationFn: async () => {
|
||||
await agent.app.bsky.contact.startPhoneVerification({phone: phone})
|
||||
},
|
||||
onSuccess: () => {
|
||||
dispatch({type: 'RESEND_VERIFICATION_CODE'})
|
||||
Toast.show(_(msg`A new code has been sent`))
|
||||
},
|
||||
onMutate: () => {
|
||||
setOtpCode('')
|
||||
setError(null)
|
||||
},
|
||||
onError: err => {
|
||||
if (isNetworkError(err)) {
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: true,
|
||||
message: _(
|
||||
msg`A network error occurred. Please check your internet connection.`,
|
||||
),
|
||||
})
|
||||
} else if (
|
||||
err instanceof AppBskyContactStartPhoneVerification.InvalidPhoneError
|
||||
) {
|
||||
setError({
|
||||
retryable: false,
|
||||
isResendError: true,
|
||||
message: _(
|
||||
msg`The verification provider was unable to send a code to your phone number. Please check your phone number and try again.`,
|
||||
),
|
||||
})
|
||||
} else if (
|
||||
err instanceof
|
||||
AppBskyContactStartPhoneVerification.RateLimitExceededError
|
||||
) {
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: true,
|
||||
message: _(
|
||||
msg`Too many codes sent. Please wait a few minutes and try again.`,
|
||||
),
|
||||
})
|
||||
} else {
|
||||
logger.error('Resend failed', {safeMessage: err})
|
||||
setError({
|
||||
retryable: true,
|
||||
isResendError: true,
|
||||
message: _(msg`An error occurred. ${cleanError(err)}`),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const onPressBack = useOnPressBackButton()
|
||||
|
||||
return (
|
||||
<View style={[a.h_full]}>
|
||||
<Layout.Header.Outer noBottomBorder>
|
||||
<Layout.Header.BackButton onPress={onPressBack} />
|
||||
<Layout.Header.Content />
|
||||
{context === 'Onboarding' ? (
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
label={_(msg`Skip contact sharing and continue to the app`)}
|
||||
onPress={onSkip}>
|
||||
<ButtonText>
|
||||
<Trans>Skip</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : (
|
||||
<Layout.Header.Slot />
|
||||
)}
|
||||
</Layout.Header.Outer>
|
||||
<Layout.Content
|
||||
contentContainerStyle={[gutters, a.pt_sm, a.flex_1]}
|
||||
keyboardShouldPersistTaps="always">
|
||||
{context === 'Onboarding' && <OnboardingPosition />}
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
<Trans>Verify phone number</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
a.mt_sm,
|
||||
]}>
|
||||
<Trans>Enter the 6-digit code sent to {prettyNumber}</Trans>
|
||||
</Text>
|
||||
<View style={[a.mt_2xl]}>
|
||||
<OTPInput
|
||||
label={_(
|
||||
msg`Enter 6-digit code that was sent to your phone number`,
|
||||
)}
|
||||
value={otpCode}
|
||||
onChange={setOtpCode}
|
||||
onComplete={code => verifyNumber(code)}
|
||||
/>
|
||||
</View>
|
||||
<View style={[a.mt_sm]}>
|
||||
<OTPStatus
|
||||
error={error}
|
||||
isPending={isPending}
|
||||
isResendingCode={isResendingCode}
|
||||
isSuccess={isSuccess}
|
||||
onResend={() => resendCode()}
|
||||
onRetry={() => verifyNumber(otpCode)}
|
||||
lastCodeSentAt={state.lastSentAt}
|
||||
/>
|
||||
</View>
|
||||
</Layout.Content>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Horrible component that takes all the state above and figures out what messages
|
||||
* and buttons to display.
|
||||
*/
|
||||
function OTPStatus({
|
||||
error,
|
||||
isPending,
|
||||
isResendingCode,
|
||||
isSuccess,
|
||||
onResend,
|
||||
onRetry,
|
||||
lastCodeSentAt,
|
||||
}: {
|
||||
error: {
|
||||
retryable: boolean
|
||||
isResendError: boolean
|
||||
message: string
|
||||
} | null
|
||||
isPending: boolean
|
||||
isResendingCode: boolean
|
||||
isSuccess: boolean
|
||||
onResend: () => void
|
||||
onRetry: () => void
|
||||
lastCodeSentAt: Date | null
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const [time, setTime] = useState(Date.now())
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTime(Date.now())
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const timeUntilCanResend = Math.max(
|
||||
0,
|
||||
30000 - (time - (lastCodeSentAt?.getTime() ?? 0)),
|
||||
)
|
||||
const isWaiting = timeUntilCanResend > 0
|
||||
|
||||
let Icon: React.ComponentType<SVGIconProps> | null = null
|
||||
let text = ''
|
||||
let textColor = t.atoms.text_contrast_medium.color
|
||||
let showResendButton = false
|
||||
let showRetryButton = false
|
||||
|
||||
if (isSuccess) {
|
||||
Icon = CircleCheckIcon
|
||||
text = _(msg`Phone number verified`)
|
||||
textColor = t.palette.positive_500
|
||||
} else if (isPending) {
|
||||
text = _(msg`Verifying...`)
|
||||
} else if (error) {
|
||||
Icon = WarningIcon
|
||||
text = error.message
|
||||
textColor = t.palette.negative_500
|
||||
if (error.retryable) {
|
||||
if (error.isResendError) {
|
||||
showResendButton = true
|
||||
} else {
|
||||
showRetryButton = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showResendButton = true
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.align_center]}>
|
||||
{text && (
|
||||
<View
|
||||
style={[
|
||||
a.gap_xs,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
(isSuccess || isPending) && a.mt_lg,
|
||||
]}>
|
||||
{Icon && <Icon size="xs" style={{color: textColor}} />}
|
||||
<Text
|
||||
style={[
|
||||
{color: textColor},
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showRetryButton && (
|
||||
<Button
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
label={_(msg`Retry`)}
|
||||
onPress={onRetry}
|
||||
style={[a.mt_2xl]}>
|
||||
<ButtonIcon icon={RetryIcon} />
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showResendButton && (
|
||||
<Button
|
||||
size="large"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
label={_(msg`Resend code`)}
|
||||
disabled={isResendingCode || isWaiting}
|
||||
onPress={onResend}
|
||||
style={[a.mt_2xl]}>
|
||||
{isResendingCode && <ButtonIcon icon={Loader} />}
|
||||
<ButtonText>
|
||||
{isWaiting ? (
|
||||
<Trans>
|
||||
Resend code in{' '}
|
||||
<NestedText style={{fontVariant: ['tabular-nums']}}>
|
||||
00:
|
||||
{String(
|
||||
clamp(Math.round(timeUntilCanResend / 1000), 0, 30),
|
||||
).padStart(2, '0')}
|
||||
</NestedText>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>Resend code</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
import {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import * as SMS from 'expo-sms'
|
||||
import {type ModerationOpts} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {wait} from '#/lib/async/wait'
|
||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
updateProfileShadow,
|
||||
useProfileShadow,
|
||||
} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {
|
||||
optimisticRemoveMatch,
|
||||
useMatchesPassthroughQuery,
|
||||
} from '#/state/queries/find-contacts'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {OnboardingPosition} from '#/screens/Onboarding/Layout'
|
||||
import {bulkWriteFollows} from '#/screens/Onboarding/util'
|
||||
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {SearchInput} from '#/components/forms/SearchInput'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||
import {MagnifyingGlassX_Stroke2_Corner0_Rounded_Large as SearchFailedIcon} from '#/components/icons/MagnifyingGlass'
|
||||
import {PersonX_Stroke2_Corner0_Rounded_Large as PersonXIcon} from '#/components/icons/Person'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {InviteInfo} from '../components/InviteInfo'
|
||||
import {type Action, type Contact, type Match, type State} from '../state'
|
||||
|
||||
type Item =
|
||||
| {
|
||||
type: 'matches header'
|
||||
count: number
|
||||
}
|
||||
| {
|
||||
type: 'match'
|
||||
match: Match
|
||||
}
|
||||
| {
|
||||
type: 'contacts header'
|
||||
}
|
||||
| {
|
||||
type: 'contact'
|
||||
contact: Contact
|
||||
}
|
||||
| {
|
||||
type: 'no matches header'
|
||||
}
|
||||
| {
|
||||
type: 'search empty state'
|
||||
query: string
|
||||
}
|
||||
| {
|
||||
type: 'totally empty state'
|
||||
}
|
||||
|
||||
export function ViewMatches({
|
||||
state,
|
||||
dispatch,
|
||||
context,
|
||||
onNext,
|
||||
}: {
|
||||
state: Extract<State, {step: '4: view matches'}>
|
||||
dispatch: React.ActionDispatch<[Action]>
|
||||
context: 'Onboarding' | 'Standalone'
|
||||
onNext: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const gutter = useGutters([0, 'wide'])
|
||||
const moderationOpts = useModerationOpts()
|
||||
const queryClient = useQueryClient()
|
||||
const agent = useAgent()
|
||||
const insets = useSafeAreaInsets()
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const {
|
||||
state: searchFocused,
|
||||
onIn: onFocus,
|
||||
onOut: onBlur,
|
||||
} = useInteractionState()
|
||||
|
||||
// HACK: Although we already have the match data, we need to pass it through
|
||||
// a query to get it into the shadow state
|
||||
const allMatches = useMatchesPassthroughQuery(state.matches)
|
||||
const matches = allMatches.filter(
|
||||
match => !state.dismissedMatches.includes(match.profile.did),
|
||||
)
|
||||
|
||||
const followableDids = matches.map(match => match.profile.did)
|
||||
const [didFollowAll, setDidFollowAll] = useState(followableDids.length === 0)
|
||||
|
||||
const cumulativeFollowCount = useRef(0)
|
||||
const onFollow = useCallback(() => {
|
||||
logger.metric('contacts:matches:follow', {entryPoint: context})
|
||||
cumulativeFollowCount.current += 1
|
||||
}, [context])
|
||||
|
||||
const {mutate: followAll, isPending: isFollowingAll} = useMutation({
|
||||
mutationFn: async () => {
|
||||
for (const did of followableDids) {
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: 'pending',
|
||||
})
|
||||
}
|
||||
|
||||
const uris = await wait(500, bulkWriteFollows(agent, followableDids))
|
||||
|
||||
for (const did of followableDids) {
|
||||
const uri = uris.get(did)
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: uri,
|
||||
})
|
||||
}
|
||||
return followableDids
|
||||
},
|
||||
onMutate: () =>
|
||||
logger.metric('contacts:matches:followAll', {
|
||||
followCount: followableDids.length,
|
||||
entryPoint: context,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setDidFollowAll(true)
|
||||
Toast.show(_(msg`All friends followed!`), {type: 'success'})
|
||||
cumulativeFollowCount.current += followableDids.length
|
||||
},
|
||||
onError: _err => {
|
||||
Toast.show(_(msg`Failed to follow all your friends, please try again`), {
|
||||
type: 'error',
|
||||
})
|
||||
for (const did of followableDids) {
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: undefined,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const items = useMemo(() => {
|
||||
const all: Item[] = []
|
||||
|
||||
if (searchFocused || search.length > 0) {
|
||||
for (const match of matches) {
|
||||
if (
|
||||
search.length === 0 ||
|
||||
(match.profile.displayName ?? '')
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase()) ||
|
||||
match.profile.handle
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase())
|
||||
) {
|
||||
all.push({type: 'match', match})
|
||||
}
|
||||
}
|
||||
|
||||
for (const contact of state.contacts) {
|
||||
if (
|
||||
search.length === 0 ||
|
||||
[contact.firstName, contact.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase())
|
||||
) {
|
||||
all.push({type: 'contact', contact})
|
||||
}
|
||||
}
|
||||
|
||||
if (all.length === 0) {
|
||||
all.push({type: 'search empty state', query: search})
|
||||
}
|
||||
} else {
|
||||
if (matches.length > 0) {
|
||||
all.push({type: 'matches header', count: matches.length})
|
||||
for (const match of matches) {
|
||||
all.push({type: 'match', match})
|
||||
}
|
||||
|
||||
if (state.contacts.length > 0) {
|
||||
all.push({type: 'contacts header'})
|
||||
}
|
||||
} else if (state.contacts.length > 0) {
|
||||
all.push({type: 'no matches header'})
|
||||
}
|
||||
|
||||
for (const contact of state.contacts) {
|
||||
all.push({type: 'contact', contact})
|
||||
}
|
||||
|
||||
if (all.length === 0) {
|
||||
all.push({type: 'totally empty state'})
|
||||
}
|
||||
}
|
||||
|
||||
return all
|
||||
}, [matches, state.contacts, search, searchFocused])
|
||||
|
||||
const {mutate: dismissMatch} = useMutation({
|
||||
mutationFn: async (did: string) => {
|
||||
await agent.app.bsky.contact.dismissMatch({subject: did})
|
||||
},
|
||||
onMutate: did => {
|
||||
logger.metric('contacts:matches:dismiss', {entryPoint: context})
|
||||
dispatch({type: 'DISMISS_MATCH', payload: {did}})
|
||||
},
|
||||
onSuccess: (_res, did) => {
|
||||
// for the other screen
|
||||
optimisticRemoveMatch(queryClient, did)
|
||||
},
|
||||
onError: (err, did) => {
|
||||
dispatch({type: 'DISMISS_MATCH_FAILED', payload: {did}})
|
||||
if (isNetworkError(err)) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Failed to hide suggestion, please check your internet connection`,
|
||||
),
|
||||
{type: 'error'},
|
||||
)
|
||||
} else {
|
||||
logger.error('Dismissing match failed', {safeMessage: err})
|
||||
Toast.show(
|
||||
_(msg`An error occurred while hiding suggestion. ${cleanError(err)}`),
|
||||
{type: 'error'},
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const renderItem = ({item}: {item: Item}) => {
|
||||
switch (item.type) {
|
||||
case 'match':
|
||||
return (
|
||||
<MatchItem
|
||||
profile={item.match.profile}
|
||||
contact={item.match.contact}
|
||||
moderationOpts={moderationOpts}
|
||||
onRemoveSuggestion={dismissMatch}
|
||||
onFollow={onFollow}
|
||||
/>
|
||||
)
|
||||
case 'contact':
|
||||
return <ContactItem contact={item.contact} context={context} />
|
||||
case 'matches header':
|
||||
return (
|
||||
<Header
|
||||
titleText={
|
||||
<Plural
|
||||
value={item.count}
|
||||
one="# friend found!"
|
||||
other="# friends found!"
|
||||
/>
|
||||
}>
|
||||
{item.count > 1 && (
|
||||
<Button
|
||||
label={_(msg`Follow all`)}
|
||||
size="small"
|
||||
color="primary_subtle"
|
||||
onPress={() => followAll()}
|
||||
disabled={isFollowingAll || didFollowAll}>
|
||||
<ButtonIcon
|
||||
icon={
|
||||
isFollowingAll
|
||||
? Loader
|
||||
: !didFollowAll
|
||||
? PlusIcon
|
||||
: CheckIcon
|
||||
}
|
||||
/>
|
||||
<ButtonText>
|
||||
<Trans>Follow all</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</Header>
|
||||
)
|
||||
case 'contacts header':
|
||||
return (
|
||||
<Header
|
||||
titleText={
|
||||
<Trans>
|
||||
Invite friends{' '}
|
||||
<InviteInfo iconStyle={t.atoms.text} iconOffset={1} />
|
||||
</Trans>
|
||||
}
|
||||
hasContentAbove
|
||||
/>
|
||||
)
|
||||
case 'no matches header':
|
||||
return (
|
||||
<Header
|
||||
titleText={_(msg`You got here first`)}
|
||||
largeTitle
|
||||
subtitleText={
|
||||
<Trans>
|
||||
Bluesky is more fun with friends. Do you want to invite some of
|
||||
yours?{' '}
|
||||
<InviteInfo
|
||||
iconStyle={t.atoms.text_contrast_medium}
|
||||
iconOffset={2}
|
||||
/>
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)
|
||||
case 'search empty state':
|
||||
return <SearchEmptyState query={item.query} />
|
||||
case 'totally empty state':
|
||||
return <TotallyEmptyState />
|
||||
}
|
||||
}
|
||||
|
||||
const isSearchEmpty = items?.[0]?.type === 'search empty state'
|
||||
const isTotallyEmpty = items?.[0]?.type === 'totally empty state'
|
||||
|
||||
const isEmpty = isSearchEmpty || isTotallyEmpty
|
||||
|
||||
return (
|
||||
<View style={[a.h_full]}>
|
||||
{context === 'Standalone' && (
|
||||
<Layout.Header.Outer noBottomBorder>
|
||||
<Layout.Header.BackButton />
|
||||
<Layout.Header.Content />
|
||||
<Layout.Header.Slot />
|
||||
</Layout.Header.Outer>
|
||||
)}
|
||||
{!isTotallyEmpty && (
|
||||
<View
|
||||
style={[
|
||||
gutter,
|
||||
a.mb_md,
|
||||
context === 'Onboarding' && [a.mt_sm, a.gap_sm],
|
||||
]}>
|
||||
{context === 'Onboarding' && <OnboardingPosition />}
|
||||
<SearchInput
|
||||
placeholder={_(msg`Search contacts`)}
|
||||
value={search}
|
||||
onFocus={() => {
|
||||
onFocus()
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onBlur={() => {
|
||||
onBlur()
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onChangeText={text => {
|
||||
setSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onClearText={() => setSearch('')}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<List
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={!isEmpty ? <ListFooter height={20} /> : null}
|
||||
keyExtractor={keyExtractor}
|
||||
keyboardDismissMode="interactive"
|
||||
automaticallyAdjustKeyboardInsets
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
a.border_t,
|
||||
a.align_center,
|
||||
a.align_stretch,
|
||||
gutter,
|
||||
a.pt_md,
|
||||
{paddingBottom: insets.bottom + tokens.space.md},
|
||||
]}>
|
||||
<Button
|
||||
label={context === 'Onboarding' ? _(msg`Next`) : _(msg`Done`)}
|
||||
onPress={() => {
|
||||
if (context === 'Onboarding') {
|
||||
logger.metric('onboarding:contacts:nextPressed', {
|
||||
matchCount: allMatches.length,
|
||||
followCount: cumulativeFollowCount.current,
|
||||
dismissedMatchCount: state.dismissedMatches.length,
|
||||
})
|
||||
}
|
||||
onNext()
|
||||
}}
|
||||
size="large"
|
||||
color="primary">
|
||||
<ButtonText>
|
||||
{context === 'Onboarding' ? (
|
||||
<Trans>Next</Trans>
|
||||
) : (
|
||||
<Trans>Done</Trans>
|
||||
)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function keyExtractor(item: Item) {
|
||||
switch (item.type) {
|
||||
case 'contact':
|
||||
return item.contact.id
|
||||
case 'match':
|
||||
return item.match.profile.did
|
||||
default:
|
||||
return item.type
|
||||
}
|
||||
}
|
||||
|
||||
function MatchItem({
|
||||
profile,
|
||||
contact,
|
||||
moderationOpts,
|
||||
onRemoveSuggestion,
|
||||
onFollow,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
contact?: Contact
|
||||
moderationOpts?: ModerationOpts
|
||||
onRemoveSuggestion: (did: string) => void
|
||||
onFollow: () => void
|
||||
}) {
|
||||
const gutter = useGutters([0, 'wide'])
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const shadow = useProfileShadow(profile)
|
||||
|
||||
const contactName = useMemo(() => {
|
||||
if (!contact) return null
|
||||
|
||||
const name = contact.name ?? contact.firstName ?? contact.lastName
|
||||
if (name) return _(msg`Your contact ${name}`)
|
||||
const phone =
|
||||
contact.phoneNumbers?.find(p => p.isPrimary) ?? contact.phoneNumbers?.[0]
|
||||
if (phone?.number) return phone.number
|
||||
return null
|
||||
}, [contact, _])
|
||||
|
||||
if (!moderationOpts) return null
|
||||
|
||||
return (
|
||||
<View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
size={48}
|
||||
/>
|
||||
<View style={[a.flex_1]}>
|
||||
<ProfileCard.Name
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
textStyle={[a.leading_tight]}
|
||||
/>
|
||||
<ProfileCard.Handle
|
||||
profile={profile}
|
||||
textStyle={[contactName && a.text_xs]}
|
||||
/>
|
||||
{contactName && (
|
||||
<Text
|
||||
emoji
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium, a.text_xs]}
|
||||
numberOfLines={1}>
|
||||
{contactName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FindContacts"
|
||||
onFollow={onFollow}
|
||||
/>
|
||||
{!shadow.viewer?.following && (
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
label={_(msg`Remove suggestion`)}
|
||||
onPress={() => onRemoveSuggestion(profile.did)}
|
||||
hoverStyle={[a.bg_transparent, {opacity: 0.5}]}
|
||||
hitSlop={8}>
|
||||
<ButtonIcon icon={XIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactItem({
|
||||
contact,
|
||||
context,
|
||||
}: {
|
||||
contact: Contact
|
||||
context: 'Onboarding' | 'Standalone'
|
||||
}) {
|
||||
const gutter = useGutters([0, 'wide'])
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const name = contact.name ?? contact.firstName ?? contact.lastName
|
||||
const phone =
|
||||
contact.phoneNumbers?.find(phone => phone.isPrimary) ??
|
||||
contact.phoneNumbers?.[0]
|
||||
const phoneNumber = phone?.number
|
||||
|
||||
return (
|
||||
<View style={[gutter, a.py_md, a.border_t, t.atoms.border_contrast_low]}>
|
||||
<ProfileCard.Header>
|
||||
{contact.image ? (
|
||||
<UserAvatar size={40} avatar={contact.image.uri} type="user" />
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
{width: 40, height: 40},
|
||||
a.rounded_full,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
t.atoms.bg_contrast_400,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.font_semi_bold,
|
||||
{color: t.palette.contrast_0},
|
||||
]}>
|
||||
{name?.[0]?.toLocaleUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_medium,
|
||||
!name && [t.atoms.text_contrast_medium, a.italic],
|
||||
]}
|
||||
numberOfLines={2}>
|
||||
{name ?? <Trans>No name</Trans>}
|
||||
</Text>
|
||||
{phoneNumber && currentAccount && (
|
||||
<Button
|
||||
label={_(msg`Invite ${name} to join Bluesky`)}
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={async () => {
|
||||
logger.metric('contacts:matches:invite', {
|
||||
entryPoint: context,
|
||||
})
|
||||
try {
|
||||
await SMS.sendSMSAsync(
|
||||
[phoneNumber],
|
||||
_(
|
||||
msg`I'm on Bluesky as ${currentAccount.handle} - come find me! https://bsky.app/download`,
|
||||
),
|
||||
)
|
||||
} catch (err) {
|
||||
Toast.show(_(msg`Failed to launch SMS app`), {type: 'error'})
|
||||
logger.error('Could not launch SMS', {safeMessage: err})
|
||||
}
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Invite</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</ProfileCard.Header>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Header({
|
||||
titleText,
|
||||
largeTitle,
|
||||
subtitleText,
|
||||
children,
|
||||
hasContentAbove,
|
||||
}: {
|
||||
titleText: React.ReactNode
|
||||
largeTitle?: boolean
|
||||
subtitleText?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
hasContentAbove?: boolean
|
||||
}) {
|
||||
const gutter = useGutters([0, 'wide'])
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
gutter,
|
||||
a.pb_md,
|
||||
a.gap_sm,
|
||||
hasContentAbove
|
||||
? [a.pt_4xl, a.border_t, t.atoms.border_contrast_low]
|
||||
: a.pt_md,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Text style={[largeTitle ? a.text_3xl : a.text_xl, a.font_bold]}>
|
||||
{titleText}
|
||||
</Text>
|
||||
{children}
|
||||
</View>
|
||||
{subtitleText && (
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{subtitleText}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchEmptyState({query}: {query: string}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.gap_lg,
|
||||
a.pt_5xl,
|
||||
a.px_5xl,
|
||||
]}>
|
||||
<SearchFailedIcon width={64} style={[t.atoms.text_contrast_low]} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_center,
|
||||
]}>
|
||||
<Trans>No contacts with the name “{query}” found</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function TotallyEmptyState() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_col,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.gap_lg,
|
||||
{paddingTop: 140},
|
||||
a.px_5xl,
|
||||
]}>
|
||||
<PersonXIcon width={64} style={[t.atoms.text_contrast_low]} />
|
||||
<Text style={[a.text_xl, a.font_bold, a.leading_snug, a.text_center]}>
|
||||
<Trans>No contacts found</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import {createContext, useContext, useReducer} from 'react'
|
||||
import {type GestureResponderEvent} from 'react-native'
|
||||
import {type ExistingContact} from 'expo-contacts'
|
||||
|
||||
import {type CountryCode} from '#/lib/international-telephone-codes'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export type Contact = ExistingContact
|
||||
|
||||
export type Match = {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
contact?: Contact
|
||||
}
|
||||
|
||||
export type State =
|
||||
| {
|
||||
step: '1: phone input'
|
||||
phoneCountryCode?: CountryCode
|
||||
phoneNumber?: string
|
||||
}
|
||||
| {
|
||||
step: '2: verify number'
|
||||
phoneCountryCode: CountryCode
|
||||
phoneNumber: string
|
||||
lastSentAt: Date | null
|
||||
}
|
||||
| {
|
||||
step: '3: get contacts'
|
||||
phoneCountryCode: CountryCode
|
||||
phoneNumber: string
|
||||
token: string
|
||||
contacts?: Contact[]
|
||||
}
|
||||
| {
|
||||
step: '4: view matches'
|
||||
contacts: Contact[]
|
||||
matches: Match[]
|
||||
// rather than mutating `matches`, we keep track of dismissed matches
|
||||
// so we can roll back optimistic updates
|
||||
dismissedMatches: string[]
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
type: 'SUBMIT_PHONE_NUMBER'
|
||||
payload: {
|
||||
phoneCountryCode: CountryCode
|
||||
phoneNumber: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'RESEND_VERIFICATION_CODE'
|
||||
}
|
||||
| {
|
||||
type: 'VERIFY_PHONE_NUMBER_SUCCESS'
|
||||
payload: {
|
||||
token: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'GET_CONTACTS_SUCCESS'
|
||||
payload: {
|
||||
contacts: Contact[]
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'SYNC_CONTACTS_SUCCESS'
|
||||
payload: {
|
||||
matches: Match[]
|
||||
// filter out matched contacts
|
||||
contacts: Contact[]
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'BACK'
|
||||
}
|
||||
| {
|
||||
type: 'DISMISS_MATCH'
|
||||
payload: {
|
||||
did: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'DISMISS_MATCH_FAILED'
|
||||
payload: {
|
||||
did: string
|
||||
}
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'SUBMIT_PHONE_NUMBER': {
|
||||
assertCurrentStep(state, '1: phone input')
|
||||
return {
|
||||
step: '2: verify number',
|
||||
...action.payload,
|
||||
lastSentAt: null,
|
||||
}
|
||||
}
|
||||
case 'RESEND_VERIFICATION_CODE': {
|
||||
assertCurrentStep(state, '2: verify number')
|
||||
return {
|
||||
...state,
|
||||
lastSentAt: new Date(),
|
||||
}
|
||||
}
|
||||
case 'VERIFY_PHONE_NUMBER_SUCCESS': {
|
||||
assertCurrentStep(state, '2: verify number')
|
||||
return {
|
||||
step: '3: get contacts',
|
||||
token: action.payload.token,
|
||||
phoneCountryCode: state.phoneCountryCode,
|
||||
phoneNumber: state.phoneNumber,
|
||||
}
|
||||
}
|
||||
case 'BACK': {
|
||||
assertCurrentStep(state, '2: verify number')
|
||||
return {
|
||||
step: '1: phone input',
|
||||
phoneNumber: state.phoneNumber,
|
||||
phoneCountryCode: state.phoneCountryCode,
|
||||
}
|
||||
}
|
||||
case 'GET_CONTACTS_SUCCESS': {
|
||||
assertCurrentStep(state, '3: get contacts')
|
||||
return {
|
||||
...state,
|
||||
contacts: action.payload.contacts,
|
||||
}
|
||||
}
|
||||
case 'SYNC_CONTACTS_SUCCESS': {
|
||||
assertCurrentStep(state, '3: get contacts')
|
||||
return {
|
||||
step: '4: view matches',
|
||||
contacts: action.payload.contacts,
|
||||
matches: action.payload.matches,
|
||||
dismissedMatches: [],
|
||||
}
|
||||
}
|
||||
case 'DISMISS_MATCH': {
|
||||
assertCurrentStep(state, '4: view matches')
|
||||
return {
|
||||
...state,
|
||||
dismissedMatches: [
|
||||
...new Set(state.dismissedMatches),
|
||||
action.payload.did,
|
||||
],
|
||||
}
|
||||
}
|
||||
case 'DISMISS_MATCH_FAILED': {
|
||||
assertCurrentStep(state, '4: view matches')
|
||||
return {
|
||||
...state,
|
||||
dismissedMatches: state.dismissedMatches.filter(
|
||||
did => did !== action.payload.did,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class InvalidStateTransitionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'InvalidStateTransitionError'
|
||||
}
|
||||
}
|
||||
|
||||
function assertCurrentStep<S extends State['step']>(
|
||||
state: State,
|
||||
step: S,
|
||||
): asserts state is Extract<State, {step: S}> {
|
||||
if (state.step !== step) {
|
||||
throw new InvalidStateTransitionError(
|
||||
`Invalid state transition: expecting ${step}, got ${state.step}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function useFindContactsFlowState(
|
||||
initialState: State = {step: '1: phone input'},
|
||||
) {
|
||||
return useReducer(reducer, initialState)
|
||||
}
|
||||
|
||||
export const FindContactsGoBackContext = createContext<
|
||||
(() => void) | undefined
|
||||
>(undefined)
|
||||
export function useOnPressBackButton() {
|
||||
const goBack = useContext(FindContactsGoBackContext)
|
||||
if (!goBack) {
|
||||
return undefined
|
||||
}
|
||||
return (evt: GestureResponderEvent) => {
|
||||
evt.preventDefault()
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useCleanError} from '#/lib/hooks/useCleanError'
|
||||
import {isAppPassword} from '#/lib/jwt'
|
||||
import {getAge, getDateAgo} from '#/lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
usePreferencesQuery,
|
||||
type UsePreferencesQueryResponse,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
@@ -23,7 +25,7 @@ import * as Dialog from '#/components/Dialog'
|
||||
import {DateField} from '#/components/forms/DateField'
|
||||
import {SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Span, Text} from '#/components/Typography'
|
||||
|
||||
export function BirthDateSettingsDialog({
|
||||
control,
|
||||
@@ -34,6 +36,8 @@ export function BirthDateSettingsDialog({
|
||||
const {_} = useLingui()
|
||||
const {isLoading, error, data: preferences} = usePreferencesQuery()
|
||||
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
|
||||
const {currentAccount} = useSession()
|
||||
const isUsingAppPassword = isAppPassword(currentAccount?.accessJwt || '')
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
@@ -65,6 +69,15 @@ export function BirthDateSettingsDialog({
|
||||
}
|
||||
style={[a.rounded_sm]}
|
||||
/>
|
||||
) : isUsingAppPassword ? (
|
||||
<Admonition type="info">
|
||||
<Trans>
|
||||
Hmm, it looks like you're logged in with an{' '}
|
||||
<Span style={[a.italic]}>App Password</Span>. To set your
|
||||
birthdate, you'll need to log in with your main account
|
||||
password, or ask whomever controls this account to do so.
|
||||
</Trans>
|
||||
</Admonition>
|
||||
) : (
|
||||
<BirthdayInner control={control} preferences={preferences} />
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
|
||||
export function GifSelectDialog({
|
||||
|
||||
@@ -25,7 +25,7 @@ import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -5,7 +5,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {logger} from '#/logger'
|
||||
@@ -259,11 +259,11 @@ function DialogInner({
|
||||
_,
|
||||
])
|
||||
|
||||
const displayNameTooLong = useWarnMaxGraphemeCount({
|
||||
const displayNameTooLong = isOverMaxGraphemeCount({
|
||||
text: displayName,
|
||||
maxCount: DISPLAY_NAME_MAX_GRAPHEMES,
|
||||
})
|
||||
const descriptionTooLong = useWarnMaxGraphemeCount({
|
||||
const descriptionTooLong = isOverMaxGraphemeCount({
|
||||
text: descriptionRt,
|
||||
maxCount: DESCRIPTION_MAX_GRAPHEMES,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {isFindContactsFeatureEnabled} from '#/components/contacts/country-allowlist'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {
|
||||
createIsEnabledCheck,
|
||||
isExistingUserAsOf,
|
||||
} from '#/components/dialogs/nuxs/utils'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_E2E} from '#/env'
|
||||
import {navigate} from '#/Navigation'
|
||||
|
||||
export const enabled = createIsEnabledCheck(props => {
|
||||
return (
|
||||
!IS_E2E &&
|
||||
isNative &&
|
||||
isExistingUserAsOf(
|
||||
'2025-12-16T00:00:00.000Z',
|
||||
props.currentProfile.createdAt,
|
||||
) &&
|
||||
isFindContactsFeatureEnabled(props.geolocation.countryCode)
|
||||
)
|
||||
})
|
||||
|
||||
export function FindContactsAnnouncement() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
Dialog.useAutoOpen(control)
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
}, [nuxDialogs])
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Introducing finding friends via contacts`)}
|
||||
style={[web({maxWidth: 440})]}
|
||||
contentContainerStyle={[
|
||||
{
|
||||
paddingTop: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.align_center, a.pt_3xl]}>
|
||||
<LinearGradient
|
||||
colors={[t.palette.primary_200, t.atoms.bg.backgroundColor]}
|
||||
locations={[0, 1]}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 0, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
<View style={[a.w_full, a.pt_sm, a.px_5xl, a.pb_4xl]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={require('../../../../assets/images/find_friends_illustration.webp')}
|
||||
style={[a.w_full, {aspectRatio: 1278 / 661}]}
|
||||
alt={_(
|
||||
msg`An illustration depicting user avatars flowing from a contact book into the Bluesky app`,
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.align_center, a.px_xl, a.gap_5xl]}>
|
||||
<View style={[a.gap_sm, a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_4xl,
|
||||
a.leading_tight,
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
{
|
||||
fontSize: isWeb ? 28 : 32,
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
<Trans>Find your friends</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
{maxWidth: 340},
|
||||
]}>
|
||||
<Trans>
|
||||
Bluesky is more fun with friends! Import your contacts to see
|
||||
who’s already here.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={_(msg`Import Contacts`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
onPress={() => {
|
||||
logger.metric('contacts:nux:ctaPressed', {})
|
||||
control.close(() => {
|
||||
navigate('FindContactsFlow')
|
||||
})
|
||||
}}
|
||||
style={[a.w_full]}>
|
||||
<ButtonText>
|
||||
<Trans>Import Contacts</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
@@ -12,12 +19,13 @@ import {
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useOnboardingState} from '#/state/shell'
|
||||
import {BookmarksAnnouncement} from '#/components/dialogs/nuxs/BookmarksAnnouncement'
|
||||
/*
|
||||
* NUXs
|
||||
*/
|
||||
import {
|
||||
enabled as isFindContactsAnnouncementEnabled,
|
||||
FindContactsAnnouncement,
|
||||
} from '#/components/dialogs/nuxs/FindContactsAnnouncement'
|
||||
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||
import {isExistingUserAsOf} from './utils'
|
||||
import {type EnabledCheckProps} from '#/components/dialogs/nuxs/utils'
|
||||
import {useGeolocation} from '#/geolocation'
|
||||
|
||||
type Context = {
|
||||
activeNux: Nux | undefined
|
||||
@@ -26,32 +34,22 @@ type Context = {
|
||||
|
||||
const queuedNuxs: {
|
||||
id: Nux
|
||||
enabled?: (props: {
|
||||
gate: ReturnType<typeof useGate>
|
||||
currentAccount: SessionAccount
|
||||
currentProfile: AppBskyActorDefs.ProfileViewDetailed
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) => boolean
|
||||
enabled?: (props: EnabledCheckProps) => boolean
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.BookmarksAnnouncement,
|
||||
enabled: ({currentProfile}) => {
|
||||
return isExistingUserAsOf(
|
||||
'2025-09-08T00:00:00.000Z',
|
||||
currentProfile.createdAt,
|
||||
)
|
||||
},
|
||||
id: Nux.FindContactsAnnouncement,
|
||||
enabled: isFindContactsAnnouncementEnabled,
|
||||
},
|
||||
]
|
||||
|
||||
const Context = React.createContext<Context>({
|
||||
const Context = createContext<Context>({
|
||||
activeNux: undefined,
|
||||
dismissActiveNux: () => {},
|
||||
})
|
||||
Context.displayName = 'NuxDialogContext'
|
||||
|
||||
export function useNuxDialogContext() {
|
||||
return React.useContext(Context)
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export function NuxDialogs() {
|
||||
@@ -91,20 +89,21 @@ function Inner({
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const gate = useGate()
|
||||
const geolocation = useGeolocation()
|
||||
const {nuxs} = useNuxs()
|
||||
const [snoozed, setSnoozed] = React.useState(() => {
|
||||
const [snoozed, setSnoozed] = useState(() => {
|
||||
return isSnoozed()
|
||||
})
|
||||
const [activeNux, setActiveNux] = React.useState<Nux | undefined>()
|
||||
const [activeNux, setActiveNux] = useState<Nux | undefined>()
|
||||
const {mutateAsync: saveNux} = useSaveNux()
|
||||
const {mutate: resetNuxs} = useResetNuxs()
|
||||
|
||||
const snoozeNuxDialog = React.useCallback(() => {
|
||||
const snoozeNuxDialog = useCallback(() => {
|
||||
snooze()
|
||||
setSnoozed(true)
|
||||
}, [setSnoozed])
|
||||
|
||||
const dismissActiveNux = React.useCallback(() => {
|
||||
const dismissActiveNux = useCallback(() => {
|
||||
if (!activeNux) return
|
||||
setActiveNux(undefined)
|
||||
}, [activeNux, setActiveNux])
|
||||
@@ -118,7 +117,7 @@ function Inner({
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (snoozed) return // comment this out to test
|
||||
if (!nuxs) return
|
||||
|
||||
@@ -133,7 +132,13 @@ function Inner({
|
||||
// then check gate (track exposure)
|
||||
if (
|
||||
enabled &&
|
||||
!enabled({gate, currentAccount, currentProfile, preferences})
|
||||
!enabled({
|
||||
gate,
|
||||
currentAccount,
|
||||
currentProfile,
|
||||
preferences,
|
||||
geolocation,
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -168,9 +173,10 @@ function Inner({
|
||||
currentAccount,
|
||||
currentProfile,
|
||||
preferences,
|
||||
geolocation,
|
||||
])
|
||||
|
||||
const ctx = React.useMemo(() => {
|
||||
const ctx = useMemo(() => {
|
||||
return {
|
||||
activeNux,
|
||||
dismissActiveNux,
|
||||
@@ -180,7 +186,9 @@ function Inner({
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
|
||||
{activeNux === Nux.BookmarksAnnouncement && <BookmarksAnnouncement />}
|
||||
{activeNux === Nux.FindContactsAnnouncement && (
|
||||
<FindContactsAnnouncement />
|
||||
)}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
|
||||
import {type useGate} from '#/lib/statsig/statsig'
|
||||
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences'
|
||||
import {type SessionAccount} from '#/state/session'
|
||||
import {type Geolocation} from '#/geolocation'
|
||||
|
||||
export type EnabledCheckProps = {
|
||||
gate: ReturnType<typeof useGate>
|
||||
currentAccount: SessionAccount
|
||||
currentProfile: AppBskyActorDefs.ProfileViewDetailed
|
||||
preferences: UsePreferencesQueryResponse
|
||||
geolocation: Geolocation
|
||||
}
|
||||
|
||||
export function createIsEnabledCheck(
|
||||
cb: (props: EnabledCheckProps) => boolean,
|
||||
) {
|
||||
return cb
|
||||
}
|
||||
|
||||
const ONE_DAY = 1000 * 60 * 60 * 24
|
||||
|
||||
export function isDaysOld(days: number, createdAt?: string) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass2'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
|
||||
type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
|
||||
|
||||
@@ -141,17 +141,28 @@ export function useSharedInputStyles() {
|
||||
}, [t])
|
||||
}
|
||||
|
||||
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||
export type InputProps = Omit<
|
||||
TextInputProps,
|
||||
'value' | 'onChangeText' | 'placeholder'
|
||||
> & {
|
||||
label: string
|
||||
/**
|
||||
* @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible.
|
||||
*
|
||||
* See https://github.com/facebook/react-native-website/pull/4247
|
||||
*
|
||||
* Note: This guidance no longer applies once we migrate to the New Architecture!
|
||||
*/
|
||||
value?: string
|
||||
onChangeText?: (value: string) => void
|
||||
isInvalid?: boolean
|
||||
inputRef?: React.RefObject<TextInput | null> | React.ForwardedRef<TextInput>
|
||||
/**
|
||||
* Note: this currently falls back to the label if not specified. However,
|
||||
* most new designs have no placeholder. We should eventually remove this fallback
|
||||
* behaviour, but for now just pass `null` if you want no placeholder -sfn
|
||||
*/
|
||||
placeholder?: string | null | undefined
|
||||
}
|
||||
|
||||
export function createInput(Component: typeof TextInput) {
|
||||
@@ -255,7 +266,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
ctx.onBlur()
|
||||
onBlur?.(e)
|
||||
}}
|
||||
placeholder={placeholder || label}
|
||||
placeholder={placeholder === null ? undefined : placeholder || label}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
style={flattened}
|
||||
@@ -292,12 +303,7 @@ export function LabelText({
|
||||
return (
|
||||
<Text
|
||||
nativeID={nativeID}
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.font_semi_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
a.mb_sm,
|
||||
]}>
|
||||
style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium, a.mb_sm]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -4,3 +4,9 @@ export const ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded =
|
||||
createSinglePathSVG({
|
||||
path: 'M5 3a1 1 0 0 1 1 1v1.423c.498-.46 1.02-.869 1.58-1.213C8.863 3.423 10.302 3 12.028 3a9 9 0 1 1-8.487 12 1 1 0 0 1 1.885-.667A7 7 0 1 0 12.028 5c-1.37 0-2.444.327-3.402.915-.474.29-.93.652-1.383 1.085H9a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowRotateClockwise_Stroke2_Corner0_Rounded = createSinglePathSVG(
|
||||
{
|
||||
path: 'M2.972 12a9 9 0 0 1 9-9c1.726 0 3.165.423 4.448 1.21.561.344 1.082.756 1.58 1.218V4a1 1 0 1 1 2 0v4a1 1 0 0 1-1 1h-4a1 1 0 1 1 0-2h1.756a8.3 8.3 0 0 0-1.382-1.085C14.417 5.327 13.341 5 11.972 5a7 7 0 1 0 6.601 9.333A1 1 0 0 1 20.46 15a9 9 0 0 1-17.487-3Z',
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Contacts_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M7 4a1 1 0 0 0-1 1v12.05q.243-.05.5-.05H18V4H7Zm5 7c.894 0 1.57.188 2.068.513.445.29.683.647.806.937l.046.12c.285.836-.43 1.43-1.03 1.43h-3.78c-.6 0-1.315-.594-1.03-1.43l.046-.12c.123-.29.361-.647.806-.937C10.43 11.188 11.106 11 12 11Zm0-4a1.75 1.75 0 1 1 0 3.5A1.75 1.75 0 0 1 12 7Zm8 11a1 1 0 0 1-1 1H6.5a.5.5 0 0 0 0 1H19a1 1 0 1 1 0 2H6.5A2.5 2.5 0 0 1 4 19.5V5a3 3 0 0 1 3-3h11a2 2 0 0 1 2 2v14Z',
|
||||
})
|
||||
|
||||
export const Contacts_Filled_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M7 2a3 3 0 0 0-3 3v14.5A2.5 2.5 0 0 0 6.5 22H19a1 1 0 1 0 0-2H6.5a.5.5 0 0 1 0-1H19a1 1 0 0 0 1-1V4a2 2 0 0 0-2-2H7Zm5 5a1.75 1.75 0 1 0 0 3.5A1.75 1.75 0 0 0 12 7Zm0 4c-.894 0-1.57.188-2.068.512a2.07 2.07 0 0 0-.852 1.058c-.286.838.432 1.43 1.03 1.43h3.78c.598 0 1.316-.592 1.03-1.43a2.07 2.07 0 0 0-.852-1.058C13.57 11.189 12.894 11 12 11Z',
|
||||
})
|
||||
@@ -1,6 +1,20 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const MagnifyingGlass_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
|
||||
})
|
||||
|
||||
export const MagnifyingGlass_Filled_Stroke2_Corner0_Rounded =
|
||||
createSinglePathSVG({
|
||||
path: 'M5 11a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm6-8a8 8 0 1 0 4.906 14.32l3.387 3.387a1 1 0 0 0 1.414-1.414l-3.387-3.387A8 8 0 0 0 11 3Zm4 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z',
|
||||
})
|
||||
|
||||
export const MagnifyingGlassX_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M20 12a8 8 0 1 0-8 8c2.204 0 4.2-.89 5.648-2.333A7.97 7.97 0 0 0 20 12Zm-5.707-3.707a1 1 0 1 1 1.414 1.414L13.414 12l2.293 2.293a1 1 0 1 1-1.414 1.414L12 13.414l-2.293 2.293a1 1 0 1 1-1.414-1.414L10.586 12 8.293 9.707a1 1 0 1 1 1.414-1.414L12 10.586l2.293-2.293ZM22 12a9.96 9.96 0 0 1-2.269 6.34l2.891 2.89a1 1 0 1 1-1.414 1.415l-2.893-2.893A9.96 9.96 0 0 1 12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10Z',
|
||||
})
|
||||
|
||||
export const MagnifyingGlassX_Stroke2_Corner0_Rounded_Large =
|
||||
createSinglePathSVG({
|
||||
viewBox: '0 0 64 64',
|
||||
path: 'M55 32C55 19.298 44.703 9 32 9S9 19.298 9 32s10.298 23 23 23a22.93 22.93 0 0 0 16.235-6.708A22.93 22.93 0 0 0 55 32Zm-15.707-8.707a1 1 0 1 1 1.414 1.414L33.414 32l7.293 7.293a1 1 0 1 1-1.414 1.414L32 33.414l-7.293 7.293a1 1 0 1 1-1.414-1.414L30.586 32l-7.293-7.293a1 1 0 1 1 1.414-1.414L32 30.586l7.293-7.293ZM57 32a24.9 24.9 0 0 1-6.66 16.985l8.808 8.808a1 1 0 0 1-1.414 1.414l-8.81-8.81A24.9 24.9 0 0 1 32 57C18.193 57 7 45.807 7 32S18.193 7 32 7s25 11.193 25 25Z',
|
||||
})
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
|
||||
})
|
||||
@@ -16,6 +16,11 @@ export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
|
||||
export const PersonX_Stroke2_Corner0_Rounded_Large = createSinglePathSVG({
|
||||
viewBox: '0 0 64 64',
|
||||
path: 'M24.952 33.435c10.897 0 19.195 6.496 22.58 15.65.784 2.117.258 4.183-1 5.683-1.242 1.48-3.194 2.414-5.33 2.415h-32.5c-2.136 0-4.089-.935-5.331-2.415-1.258-1.5-1.783-3.566-1-5.682 3.386-9.155 11.683-15.651 22.58-15.651Zm0 2.298c-9.885 0-17.355 5.849-20.425 14.15-.47 1.271-.174 2.48.604 3.407.795.947 2.096 1.594 3.57 1.594h32.5c1.476 0 2.777-.647 3.571-1.594.778-.928 1.074-2.136.605-3.406-3.07-8.302-10.54-14.15-20.425-14.15Zm33.262-14.59a1.15 1.15 0 1 1 1.625 1.626l-5.688 5.687 5.686 5.688a1.15 1.15 0 1 1-1.625 1.625l-5.686-5.688-5.687 5.688a1.15 1.15 0 1 1-1.625-1.625l5.687-5.688-5.689-5.687a1.15 1.15 0 1 1 1.625-1.625l5.689 5.687 5.688-5.687ZM24.949 5.552c6.557 0 11.874 5.316 11.874 11.874 0 6.557-5.317 11.874-11.874 11.874s-11.874-5.317-11.874-11.874S18.39 5.55 24.949 5.55Zm0 2.299a9.575 9.575 0 0 0-9.575 9.575A9.575 9.575 0 0 0 24.949 27a9.575 9.575 0 0 0 9.575-9.575A9.575 9.575 0 0 0 24.95 7.85Z',
|
||||
})
|
||||
|
||||
export const PersonPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19c.71-2.909 3.092-5 6.322-5 .621 0 1.206.077 1.748.218a1 1 0 1 0 .504-1.936A8.931 8.931 0 0 0 12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H11a1 1 0 1 0 0-2H5.678ZM18 14a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as Dialog from '#/components/Dialog'
|
||||
import {type DialogControlProps} from '#/components/Dialog'
|
||||
import {useConfirmEmail} from '#/components/dialogs/EmailDialog/data/useConfirmEmail'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotate'
|
||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type ScrollView} from 'react-native-gesture-handler'
|
||||
import {Pressable, type ScrollView, View} from 'react-native'
|
||||
import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -18,7 +17,7 @@ import * as Admonition from '#/components/Admonition'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate'
|
||||
import {
|
||||
Check_Stroke2_Corner0_Rounded as CheckThin,
|
||||
CheckThick_Stroke2_Corner0_Rounded as Check,
|
||||
|
||||
@@ -203,9 +203,15 @@ export const EMOJI_REACTION_LIMIT = 5
|
||||
export const urls = {
|
||||
website: {
|
||||
blog: {
|
||||
findFriendsAnnouncement:
|
||||
'https://bsky.social/about/blog/12-16-2025-find-friends',
|
||||
initialVerificationAnnouncement: `https://bsky.social/about/blog/04-21-2025-verification`,
|
||||
searchTipsAndTricks: 'https://bsky.social/about/blog/05-31-2024-search',
|
||||
},
|
||||
support: {
|
||||
findFriendsPrivacyPolicy:
|
||||
'https://bsky.social/about/support/find-friends-privacy-policy',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import {type CountryCode as LibPhoneNumberJsCountryCode} from 'libphonenumber-js'
|
||||
|
||||
// Exclude Ascension Island and Tristan da Cunha - merged into `SH` in 2009
|
||||
export type CountryCode = Exclude<LibPhoneNumberJsCountryCode, 'AC' | 'TA'>
|
||||
|
||||
/**
|
||||
* Note: data is from Wikipedia, but some have been removed to match `libphonenumber-js`
|
||||
* Mostly tiny British overseas territories + Antarctica, all of which
|
||||
* share codes with a larger country. If you've one of the 10 people from these
|
||||
* places, you probably know what to do.
|
||||
*/
|
||||
export const INTERNATIONAL_TELEPHONE_CODES = {
|
||||
AD: {
|
||||
code: '+376',
|
||||
@@ -34,11 +45,13 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
|
||||
unicodeFlag: '🇦🇴',
|
||||
svgFlag: require('../../assets/icons/flags/AO.svg'),
|
||||
},
|
||||
AQ: {
|
||||
code: '+672',
|
||||
unicodeFlag: '🇦🇶',
|
||||
svgFlag: require('../../assets/icons/flags/AQ.svg'),
|
||||
},
|
||||
// sorry penguins :(
|
||||
// same as Norfolk Island
|
||||
// AQ: {
|
||||
// code: '+672',
|
||||
// unicodeFlag: '🇦🇶',
|
||||
// svgFlag: require('../../assets/icons/flags/AQ.svg'),
|
||||
// },
|
||||
AR: {
|
||||
code: '+54',
|
||||
unicodeFlag: '🇦🇷',
|
||||
@@ -154,11 +167,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
|
||||
unicodeFlag: '🇧🇹',
|
||||
svgFlag: require('../../assets/icons/flags/BT.svg'),
|
||||
},
|
||||
BV: {
|
||||
code: '+47',
|
||||
unicodeFlag: '🇧🇻',
|
||||
svgFlag: require('../../assets/icons/flags/BV.svg'),
|
||||
},
|
||||
// same as Norway
|
||||
// BV: {
|
||||
// code: '+47',
|
||||
// unicodeFlag: '🇧🇻',
|
||||
// svgFlag: require('../../assets/icons/flags/BV.svg'),
|
||||
// },
|
||||
BW: {
|
||||
code: '+267',
|
||||
unicodeFlag: '🇧🇼',
|
||||
@@ -379,11 +393,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
|
||||
unicodeFlag: '🇬🇷',
|
||||
svgFlag: require('../../assets/icons/flags/GR.svg'),
|
||||
},
|
||||
GS: {
|
||||
code: '+500',
|
||||
unicodeFlag: '🇬🇸',
|
||||
svgFlag: require('../../assets/icons/flags/GS.svg'),
|
||||
},
|
||||
// same as Falkland Islands
|
||||
// GS: {
|
||||
// code: '+500',
|
||||
// unicodeFlag: '🇬🇸',
|
||||
// svgFlag: require('../../assets/icons/flags/GS.svg'),
|
||||
// },
|
||||
GT: {
|
||||
code: '+502',
|
||||
unicodeFlag: '🇬🇹',
|
||||
@@ -779,11 +794,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
|
||||
unicodeFlag: '🇵🇲',
|
||||
svgFlag: require('../../assets/icons/flags/PM.svg'),
|
||||
},
|
||||
PN: {
|
||||
code: '+64',
|
||||
unicodeFlag: '🇵🇳',
|
||||
svgFlag: require('../../assets/icons/flags/PN.svg'),
|
||||
},
|
||||
// same as New Zealand
|
||||
// PN: {
|
||||
// code: '+64',
|
||||
// unicodeFlag: '🇵🇳',
|
||||
// svgFlag: require('../../assets/icons/flags/PN.svg'),
|
||||
// },
|
||||
PR: {
|
||||
code: '+1',
|
||||
unicodeFlag: '🇵🇷',
|
||||
@@ -1199,11 +1215,12 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
|
||||
unicodeFlag: '🇫🇴',
|
||||
svgFlag: require('../../assets/icons/flags/FO.svg'),
|
||||
},
|
||||
HM: {
|
||||
code: '+672',
|
||||
unicodeFlag: '🇭🇲',
|
||||
svgFlag: require('../../assets/icons/flags/HM.svg'),
|
||||
},
|
||||
// same as Norfolk Island
|
||||
// HM: {
|
||||
// code: '+672',
|
||||
// unicodeFlag: '🇭🇲',
|
||||
// svgFlag: require('../../assets/icons/flags/HM.svg'),
|
||||
// },
|
||||
KM: {
|
||||
code: '+269',
|
||||
unicodeFlag: '🇰🇲',
|
||||
@@ -1229,16 +1246,18 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
|
||||
unicodeFlag: '🇹🇨',
|
||||
svgFlag: require('../../assets/icons/flags/TC.svg'),
|
||||
},
|
||||
TF: {
|
||||
code: '+672',
|
||||
unicodeFlag: '🇹🇫',
|
||||
svgFlag: require('../../assets/icons/flags/TF.svg'),
|
||||
},
|
||||
UM: {
|
||||
code: '+1',
|
||||
unicodeFlag: '🇺🇲',
|
||||
svgFlag: require('../../assets/icons/flags/UM.svg'),
|
||||
},
|
||||
// same as Norfolk Island
|
||||
// TF: {
|
||||
// code: '+672',
|
||||
// unicodeFlag: '🇹🇫',
|
||||
// svgFlag: require('../../assets/icons/flags/TF.svg'),
|
||||
// },
|
||||
// same as US mainland
|
||||
// UM: {
|
||||
// code: '+1',
|
||||
// unicodeFlag: '🇺🇲',
|
||||
// svgFlag: require('../../assets/icons/flags/UM.svg'),
|
||||
// },
|
||||
VA: {
|
||||
code: '+39',
|
||||
unicodeFlag: '🇻🇦',
|
||||
@@ -1249,25 +1268,26 @@ export const INTERNATIONAL_TELEPHONE_CODES = {
|
||||
unicodeFlag: '🇽🇰',
|
||||
svgFlag: require('../../assets/icons/flags/XK.svg'),
|
||||
},
|
||||
}
|
||||
} satisfies Record<
|
||||
CountryCode,
|
||||
{
|
||||
code: string
|
||||
unicodeFlag: string
|
||||
svgFlag: any
|
||||
}
|
||||
>
|
||||
|
||||
const DEFAULT_PHONE_COUNTRY = 'US'
|
||||
const DEFAULT_PHONE_COUNTRY = 'US' as const
|
||||
|
||||
export function getDefaultCountry(location?: {countryCode?: string}) {
|
||||
export function getDefaultCountry(location?: {
|
||||
countryCode?: string
|
||||
}): CountryCode {
|
||||
const locationCountryCode = location?.countryCode?.toUpperCase()
|
||||
if (
|
||||
location?.countryCode &&
|
||||
location.countryCode.toUpperCase() in INTERNATIONAL_TELEPHONE_CODES
|
||||
locationCountryCode &&
|
||||
locationCountryCode in INTERNATIONAL_TELEPHONE_CODES
|
||||
) {
|
||||
return location.countryCode.toUpperCase()
|
||||
return locationCountryCode as CountryCode
|
||||
}
|
||||
return DEFAULT_PHONE_COUNTRY
|
||||
}
|
||||
|
||||
export function getPhoneCodeFromCountryCode(countryCode: string) {
|
||||
const country =
|
||||
INTERNATIONAL_TELEPHONE_CODES[
|
||||
countryCode.toUpperCase() as keyof typeof INTERNATIONAL_TELEPHONE_CODES
|
||||
]
|
||||
if (!country) throw new Error(`Country ${countryCode} not found`)
|
||||
return country.code
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
getInfoAsync,
|
||||
readDirectoryAsync,
|
||||
} from 'expo-file-system/legacy'
|
||||
import ExpoImageCropTool, {type OpenCropperOptions} from 'expo-image-crop-tool'
|
||||
import ExpoImageCropTool, {
|
||||
type OpenCropperOptions,
|
||||
} from '@bsky.app/expo-image-crop-tool'
|
||||
|
||||
import {compressIfNeeded} from './manip'
|
||||
import {type PickerImage} from './picker.shared'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import ExpoImageCropTool, {type OpenCropperOptions} from 'expo-image-crop-tool'
|
||||
import {type ImagePickerOptions, launchCameraAsync} from 'expo-image-picker'
|
||||
import ExpoImageCropTool, {
|
||||
type OpenCropperOptions,
|
||||
} from '@bsky.app/expo-image-crop-tool'
|
||||
import {t} from '@lingui/macro'
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {type OpenCropperOptions} from 'expo-image-crop-tool'
|
||||
import {type OpenCropperOptions} from '@bsky.app/expo-image-crop-tool'
|
||||
|
||||
import {type PickerImage} from './picker.shared'
|
||||
import {type CameraOpts} from './types'
|
||||
|
||||
@@ -8,6 +8,10 @@ export function getDataUriSize(uri: string): number {
|
||||
return Math.round((uri.length * 3) / 4)
|
||||
}
|
||||
|
||||
export function isAcceptedImageMimeType(type: string): boolean {
|
||||
return ['image/jpeg', 'image/png', 'image/webp'].includes(type)
|
||||
}
|
||||
|
||||
export function isUriImage(uri: string): boolean {
|
||||
return /\.(jpg|jpeg|png|webp).*$/.test(uri)
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export type CommonNavigatorParams = {
|
||||
InterestsSettings: undefined
|
||||
AboutSettings: undefined
|
||||
AppIconSettings: undefined
|
||||
FindContactsSettings: undefined
|
||||
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
|
||||
Hashtag: {tag: string; author?: string}
|
||||
Topic: {topic: string}
|
||||
@@ -87,6 +88,7 @@ export type CommonNavigatorParams = {
|
||||
StarterPackEdit: {rkey?: string}
|
||||
VideoFeed: VideoFeedSourceContext
|
||||
Bookmarks: undefined
|
||||
FindContactsFlow: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
||||
@@ -3,6 +3,8 @@ export type Gate =
|
||||
| 'alt_share_icon'
|
||||
| 'debug_show_feedcontext'
|
||||
| 'debug_subscriptions'
|
||||
| 'disable_onboarding_find_contacts'
|
||||
| 'disable_settings_find_contacts'
|
||||
| 'explore_show_suggested_feeds'
|
||||
| 'feed_reply_button_open_thread'
|
||||
| 'old_postonboarding'
|
||||
@@ -10,5 +12,6 @@ export type Gate =
|
||||
| 'onboarding_suggested_starterpacks'
|
||||
| 'remove_show_latest_button'
|
||||
| 'show_composer_prompt'
|
||||
| 'suggested_users_dismiss'
|
||||
| 'test_gate_1'
|
||||
| 'test_gate_2'
|
||||
|
||||
@@ -64,7 +64,7 @@ export function isErrorMaybeAppPasswordPermissions(e: unknown) {
|
||||
|
||||
/**
|
||||
* Intended to capture "User cancelled" or "Crop cancelled" errors
|
||||
* that we often get from expo modules such expo-image-crop-tool
|
||||
* that we often get from expo modules such @bsky.app/expo-image-crop-tool
|
||||
*
|
||||
* The exact name has changed in the past so let's just see if the string
|
||||
* contains "cancel"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {type RichText} from '@atproto/api'
|
||||
import Graphemer from 'graphemer'
|
||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
|
||||
import {shortenLinks} from './rich-text-manip'
|
||||
|
||||
@@ -29,37 +28,18 @@ export function enforceLen(
|
||||
return str
|
||||
}
|
||||
|
||||
export function useEnforceMaxGraphemeCount() {
|
||||
const splitter = useMemo(() => new Graphemer(), [])
|
||||
|
||||
return useCallback(
|
||||
(text: string, maxCount: number) => {
|
||||
if (splitter.countGraphemes(text) > maxCount) {
|
||||
return splitter.splitGraphemes(text).slice(0, maxCount).join('')
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
},
|
||||
[splitter],
|
||||
)
|
||||
}
|
||||
|
||||
export function useWarnMaxGraphemeCount({
|
||||
export function isOverMaxGraphemeCount({
|
||||
text,
|
||||
maxCount,
|
||||
}: {
|
||||
text: string | RichText
|
||||
maxCount: number
|
||||
}) {
|
||||
const splitter = useMemo(() => new Graphemer(), [])
|
||||
|
||||
return useMemo(() => {
|
||||
if (typeof text === 'string') {
|
||||
return splitter.countGraphemes(text) > maxCount
|
||||
} else {
|
||||
return shortenLinks(text).graphemeLength > maxCount
|
||||
}
|
||||
}, [splitter, maxCount, text])
|
||||
if (typeof text === 'string') {
|
||||
return countGraphemes(text) > maxCount
|
||||
} else {
|
||||
return shortenLinks(text).graphemeLength > maxCount
|
||||
}
|
||||
}
|
||||
|
||||
export function countLines(str: string | undefined): number {
|
||||
|
||||
@@ -138,7 +138,8 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
await Promise.all([
|
||||
import('@formatjs/intl-pluralrules/locale-data/eo'),
|
||||
import('@formatjs/intl-numberformat/locale-data/eo'),
|
||||
import('@formatjs/intl-displaynames/locale-data/eo'),
|
||||
// borked, see https://github.com/bluesky-social/social-app/pull/9574
|
||||
// import('@formatjs/intl-displaynames/locale-data/eo'),
|
||||
])
|
||||
break
|
||||
}
|
||||
|
||||