Compare commits

...

55 Commits

Author SHA1 Message Date
Samuel Newman f62ae6ded1 4 -> 5 2026-01-08 11:33:43 +02:00
Samuel Newman 5668dd3258 copy content uri to file in cache dir 2026-01-08 11:33:43 +02:00
Samuel Newman e2335da909 pass through mimetype rather than assuming data uri 2026-01-08 11:33:43 +02:00
Samuel Newman 181c0c83d8 fix patch, build rn from source 2026-01-08 11:33:43 +02:00
Samuel Newman 3d3d581a7e check mimetype instead of URI 2026-01-08 11:33:43 +02:00
Samuel Newman 24038e6148 rm mattermost pasteinput 2026-01-08 11:33:43 +02:00
Samuel Newman 4fe62640f1 Patch in RN onPaste PR 2026-01-08 11:33:43 +02:00
Samuel Newman 2efc047f64 bump version to v1.113.1 (#9652) 2026-01-07 13:15:55 -08:00
Samuel Newman 80049bbe82 exclude images from bundle size workflow (#9647) 2026-01-07 02:54:59 -08:00
Samuel Newman e627d37f42 [v1.113 prep] Translations (#9646)
* intl:pull

* yarn intl:extract:all
2026-01-07 01:14:48 -08:00
pfrazee 24fe45aaa1 Nightly source-language update 2026-01-07 02:48:01 +00:00
Samuel Newman 0deb9511af Enable lightbox for profile banner (#9643)
* allow pressing banner to view

* remove special type
2026-01-06 10:37:47 -08:00
Samuel Newman 43ebb80a5b patch in proguard rules (#9447) 2026-01-06 09:31:54 -08:00
Samuel Newman d91e206e21 Fix prefetching starter pack query (#9595)
* fix prefetching starter pack query

* add comment
2026-01-06 10:58:46 -06:00
Samuel Newman 493bd6dcf6 update crop tool, use new package name (#9642) 2026-01-06 18:29:37 +02:00
Samuel Newman 4f6ff6fc73 lazy load storybook (#9612) 2026-01-06 08:03:21 -08:00
estrattonbailey 7ba2997af5 Nightly source-language update 2026-01-06 00:18:33 +00:00
Samuel Newman 4b6fbcc050 fix suggested follow card size (#9638) 2026-01-05 09:36:54 -08:00
chuanchuan 55806f1087 fix(native): dismiss recommendation bottom sheet on profile navigation (#9608) 2026-01-02 02:02:26 -08:00
Samuel Newman 0aaf11aa7b fix drawer layout on mobile web (#9618) 2025-12-30 09:56:05 -08:00
Samuel Newman 9cfa7d0ba8 Remove RNGH from web bundle (#9528)
* remove RNGH on web

* add intentionally-failing webpack alias

* Update webpack.config.js
2025-12-30 03:40:19 -08:00
Samuel Newman 9743149c26 Replace graphemer with unicode-segmenter (#9526)
* replace graphemer with unicode-segmenter

* use grapheme entrypoint

* force resolution of unicode-segmenter
2025-12-30 03:39:09 -08:00
Samuel Newman 82877a096d Manually update source languages (#9611)
* manually update source langs

* add `intl:push-sources` script
2025-12-30 02:08:26 -08:00
Samuel Newman 12dc38c5fc replace lodash.isequal with fast-deep-equal (#9529) 2025-12-30 01:47:29 -08:00
Samuel Newman 7d5de60401 fix long handles in account switch (#8373) 2025-12-30 01:35:24 -08:00
Samuel Newman 8136258cd8 add pressed state to quote embed (#9592) 2025-12-30 01:31:09 -08:00
Samuel Newman afe5db8b57 clamp gif size (#9610) 2025-12-30 01:30:49 -08:00
Samuel Newman 893dd78a00 remove unused dep (#9606) 2025-12-30 01:23:48 -08:00
Eric Bailey b38013a12f Small copy tweaks (#9557) 2025-12-29 10:21:09 -08:00
Samuel Newman aa1395542f tweak styles of composer prompt (#9524) 2025-12-29 10:19:08 -08:00
Samuel Newman caa02db87b Update react native screens, remove patch (#9210)
* update react native screens

* bump rns

* see if nightly works

* update nightly

* yay it's out

* update react-nav

* update drawer layout and remove patch

* Update createNativeStackNavigatorWithAuth.tsx

* render drawer as "layout" component

* rm react-nav/drawer (unused)

* restore drawer layout patch
2025-12-29 10:16:49 -08:00
Alex Benzer f390167f66 Remove unwanted drop shadow on "see more" card on Android (#9584) 2025-12-29 02:00:29 -08:00
surfdude29 6275d2e0fd Temporarily use English for Esperanto intl-displaynames polyfill (#9574)
* use English for Esperanto `intl-displaynames` until bug is fixed

* Update src/locale/i18n.ts

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-12-29 01:38:40 -08:00
Alex Benzer 3e1e1ba00e Better copy for "must be signed in" message (#9602) 2025-12-29 01:38:04 -08:00
hailey 699fdbac01 update actions (#9586)
* update actions

* update actions

* $

* procenv
2025-12-22 19:21:17 -08:00
pfrazee a14a9be127 Nightly source-language update 2025-12-19 02:44:03 +00:00
Eric Bailey 998ee78e3d Move NUXDialogs into shell (#9573) 2025-12-18 12:08:17 -06:00
Eric Bailey a355eedf85 Improve instructions on NoAccessScreen and bday dialog (#9571) 2025-12-18 11:05:39 -06:00
Eric Bailey 89cffdf5d0 Ignore new flag SVGs in build output (#9567) 2025-12-18 09:45:51 -06:00
pfrazee e9524d8a83 Nightly source-language update 2025-12-18 02:42:38 +00:00
Eric Bailey f50b70e2b4 Bump to v1.113.0 (#9569) 2025-12-17 17:03:16 -06:00
Alex Benzer e80e2f66c3 Adds a dismiss button to user suggestions (#9484)
* Add dismiss button to user suggestions

* Adds dismiss button to suggested user cards, behind a feature gate

* Reverse gate check, best practice

* Sync DISMISS_ANIMATION_DURATION

---------

Co-authored-by: Eric Bailey <git@esb.lol>
2025-12-17 15:45:21 -06:00
Samuel Newman f02b9c323f Change empty states if not own profile (#9518)
* change empty states if not own profile

* Update ProfileFeedgens.tsx

* capitalise starter packs
2025-12-17 14:10:39 -06:00
estrattonbailey 2200cc95d8 Nightly source-language update 2025-12-15 20:49:31 +00:00
Eric Bailey f45e58e057 [AAv2] Add blurb for orgs (#9556)
* Add blurb for orgs

* Update
2025-12-15 14:38:05 -06:00
Eric Bailey da87515d0a [APP-1684] Some contact import tweaks (#9555)
* Handle download link

* Improve NUX geo gating from #9549

* Fix alignment of phone code select

* Show full name

* Add gate to nux banner

* Add gate to settings screen

* Invert gate check in settings, whoops
2025-12-15 13:35:28 -06:00
surfdude29 348e7fa379 Restore notification count in BottomBar (#9554) 2025-12-15 09:15:52 -06:00
Samuel Newman df4e888347 disable nux in e2e (#9551) 2025-12-13 11:59:07 -08:00
Samuel Newman b48ca13172 v112 Languages (#9550)
* intl:pull

* extract
2025-12-13 11:17:05 -08:00
pfrazee 3485dd37c2 Nightly source-language update 2025-12-13 02:41:21 +00:00
Samuel Newman c9ce214a66 Gate NUX by country allowlist (#9549)
* country gate nux

* whoops
2025-12-12 12:37:14 -08:00
Samuel Newman 9971e91f9c create profile record before upload (#9547) 2025-12-12 12:32:13 -08:00
Samuel Newman 8cee40beff Find Friend links (#9546)
* links

* policy

* Update PhoneInput.tsx
2025-12-12 12:23:19 -08:00
mozzius 4ffc1263e7 Nightly source-language update 2025-12-12 18:35:53 +00:00
Samuel Newman ad7d49ef8f [Contacts] Contacts matching flow (#9486)
* add expo-contacts

* add expo-sms

* update copy

* add basic settings screen

* state machine, flow screen

* phone input screen

* otp screen

* tweak spacing

* resend code logic

* add layoutanimationconfig

* check availablility in settings screen

* get contacts

* matches screen

* search, temp setup for matches UI

* add a bunch of number parsing logic with libphonenumber

* cast to looser type

* rename sync to find

FCF (Find Contacts Flow)

* update geolocation hook

* nicer design for settings screen

* add completed state

* up border contrast

* update expo deps

* add pending spinner

* add country whitelist

* add empty state screen

* drop add more functionality

* upload -> import

* fix typo

* fix permission string

* copy updates

* rm envelope icon

* update sms copy

* add inviteinfo component

* woke is back

* [Contacts] NUXes (#9515)

* add a bunch of number parsing logic with libphonenumber

* add banner nux

* add announcement nux

* native only nux

* rm shitty animation

* move isNative check

* [Contacts] Onboarding step (#9489)

* add a bunch of number parsing logic with libphonenumber

* restructure onboarding to better support dynamic screens

* integrate existing flow into onboarding

* add intro step

* lift state up to allow going back freely

* gate onboarding by geo if unsupported country

* add done button to standalone flow

* [Contacts] Add `contact-match` notification type to feed (#9519)

* add a bunch of number parsing logic with libphonenumber

* add contact-joined notif type

* update string, api package

* Update NotificationFeedItem.tsx

* Update NotificationFeedItem.tsx

* Delete SyncContactsFlow.web.tsx

* fix follow back btn for this case

* [Contacts] API integration (#9487)

* api integration for flow

* copy tweak

* tweaks after running it

* wire up status page

* rename toast

* use api lib

* rm temp code

* maybe fix otp error

* clear code on error/resend

* add 1s delay to verify success

* update package versions

* Delete SyncContactsFlow.web.tsx

* try and fix yarn.lock lint

* even woker

* fix uppercase friends

* surfdude feedback

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* rm log

* allow resend on invalid code

* Update GetContacts.tsx

* overwrite country code if possible

* fix Apple's Best Feature

* devenv latest

* disable bounces

* interactive keyboard dismiss

* copy changes

* national format

* allow resending immediately

* move success state down a bit

* Update FindContactsSettings.tsx

* [Contacts] More onboarding changes (#9491)

* integrate existing flow into onboarding

* refreshed onboarding styles, rm stepper

* center content on web

* Add back dismiss button for internal onboarding

* Import sort

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* add matches query to shadow

* add clockwise arrow

* update status design, fix dismiss, fix queries

* add metrics

* add more comments

* Update metrics.ts

* refetch both queries on PTR

* fix shadow state in matches page

* reduce empty space at bottom

* show contact info on matches

* filter out contacts without numbers at an earlier stage

* Error handling 

* get notifs working

* filter out businesses

* rm log

* remove TODO from learn more links

* try and exclude from web bundle

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Eric Bailey <git@esb.lol>
2025-12-12 10:32:06 -08:00
206 changed files with 54694 additions and 28674 deletions
+6 -2
View File
@@ -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
+6 -2
View File
@@ -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
+15 -6
View File
@@ -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
+7 -3
View File
@@ -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 }}`
---
+12 -5
View File
@@ -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
+6 -2
View File
@@ -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
+1
View File
@@ -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
+8 -1
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

-1
View File
@@ -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)
+4 -4
View File
@@ -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 %}
+2
View File
@@ -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.
+49 -47
View File
@@ -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 parents 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 supers 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)();
-326
View File
@@ -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;
-2
View File
@@ -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>
-2
View File
@@ -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>
+38 -9
View File
@@ -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
+33 -30
View File
@@ -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>
)}
+307 -76
View File
@@ -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>
}
+3 -2
View File
@@ -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'
+29 -13
View File
@@ -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}
+8 -2
View File
@@ -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}
/>
+1 -1
View File
@@ -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 {
+4 -5
View File
@@ -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}>
&bull; <Trans>Choose who to invite</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Personalize the message</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Send the message from your phone</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<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>
)
}
+100
View File
@@ -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)
}
+162
View File
@@ -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}>
&bull; <Trans>We never keep plain phone numbers</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We delete hashes after matches are made</Trans>
</Text>
<Text style={style}>
&bull; <Trans>We only suggest follows if both people consent</Trans>
</Text>
<Text style={style}>
&bull; <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}>
&bull;{' '}
<Trans>Sent to our phone number verification provider Plivo</Trans>
</Text>
<Text style={style}>
&bull; <Trans>Deleted by Plivo after verification</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<Trans>Held by Bluesky for 7 days to prevent abuse, then deleted</Trans>
</Text>
<Text style={style}>
&bull;{' '}
<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>
)
}
+198
View File
@@ -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()
}
}
+14 -1
View File
@@ -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} />
)}
+1 -1
View File
@@ -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
whos 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>
)
}
+37 -29
View File
@@ -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>
)
}
+21
View File
@@ -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) {
+1 -1
View File
@@ -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'> & {
+14 -8
View File
@@ -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',
},
)
+9
View File
@@ -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',
})
+14
View File
@@ -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',
})
+5
View File
@@ -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,
+6
View File
@@ -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',
},
},
}
+70 -50
View File
@@ -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 -1
View File
@@ -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'
+3 -1
View File
@@ -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 -1
View File
@@ -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'
+4
View File
@@ -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)
}
+2
View File
@@ -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
View File
@@ -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'
+1 -1
View File
@@ -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"
+7 -27
View File
@@ -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 {
+2 -1
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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