Merge remote-tracking branch 'origin/main' into 3p-moderators

* origin/main: (69 commits)
  Update .po files
  use `showControls` to show/hide live text icon on ios (#2982)
  Fix dim mode unread notif color
  Make dim theme dim (#2966)
  Add handle validation to create account UI (#2959)
  Normalize relative day (#2874)
  increase timeout to 15s (#2958)
  use `useOpenLink` hook for links in ALF (#2975)
  Rename Home Feed Prefs to Following Feed Prefs (#2965)
  Refactor feed header components (#2964)
  patch react-navigation to fix history bug (#2955)
  Use EAS managed build number, run build/submit on GH Actions (#2841)
  Fix `numberOfLines` not updating on iOS 15 (#2956)
  Fix UITextView line height adjustment for DynamicType, always use the max width for the view (#2916)
  Navigate back from a deleted post's route (#2948)
  Add optional close callback to Dialog (#2947)
  Fix flash when pressing into just-created post (#2945)
  Last usage (#2944)
  Update blogpost URL in ExportCarDialog.tsx (#2939)
  Prefer full posts for post thread placeholder (#2943)
  ...
This commit is contained in:
Eric Bailey
2024-02-26 14:50:43 -06:00
143 changed files with 16079 additions and 9867 deletions
@@ -0,0 +1,61 @@
---
name: Build and Submit Android
on:
workflow_dispatch:
inputs:
profile:
type: choice
description: Build profile to use
options:
- production
jobs:
build:
name: Build and Submit Android
runs-on: ubuntu-latest
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
- name: 🔧 Setup Node
uses: actions/setup-node@v3
with:
node-version: 18.x
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: ⚙️ Install dependencies
run: yarn install
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
run: yarn use-build-number eas build -p android --profile production --local --output build.aab --non-interactive
- name: 🚀 Deploy
run: eas submit -p android --non-interactive --path build.aab
+72
View File
@@ -0,0 +1,72 @@
---
name: Build and Submit iOS
on:
schedule:
- cron: '0 5 * * *'
workflow_dispatch:
inputs:
profile:
type: choice
description: Build profile to use
options:
- production
jobs:
build:
name: Build and Submit iOS
runs-on: macos-14
steps:
- name: Check for EXPO_TOKEN
run: >
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: ⬇️ Checkout
uses: actions/checkout@v4
- name: 🔧 Setup Node
uses: actions/setup-node@v3
with:
node-version: 18.x
cache: yarn
- name: 🔨 Setup EAS
uses: expo/expo-github-action@v8
with:
expo-version: latest
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: ⛏️ Setup EAS local builds
run: yarn global add eas-cli-local-build-plugin
- name: ⚙️ Install dependencies
run: yarn install
- name: ☕️ Setup Cocoapods
uses: maxim-lobanov/setup-cocoapods@v1
with:
version: 1.14.3
- name: 💾 Cache Pods
uses: actions/cache@v3
id: pods-cache
with:
path: ./ios/Pods
# We'll use the yarn.lock for our hash since we don't yet have a Podfile.lock. Pod versions will not
# change unless the yarn version changes as well.
key: ${{ runner.os }}-pods-${{ hashFiles('yarn.lock') }}
- name: ✏️ Write environment variables
run: |
echo "${{ secrets.ENV_TOKEN }}" > .env
echo "${{ secrets.GOOGLE_SERVICES_TOKEN }}" > google-services.json
- name: 🏗️ EAS Build
run: yarn use-build-number eas build -p ios --profile production --local --output build.ipa --non-interactive
- name: 🚀 Deploy
run: eas submit -p ios --non-interactive --path build.ipa
@@ -1,52 +0,0 @@
name: Deploy Nightly Testflight Release
on:
schedule:
- cron: '0 5 * * *'
jobs:
build:
name: Deploy Nightly Testflight Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check for EXPO_TOKEN
run: |
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "You must provide an EXPO_TOKEN secret linked to this project's Expo account in this repo's secrets. Learn more: https://docs.expo.dev/eas-update/github-actions"
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18.x
cache: yarn
- name: Setup EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
run: yarn install
- name: Bump build number
run: yarn bump:ios
- name: EAS build and submit
run: eas build -p ios --profile production --auto-submit --non-interactive
- name: Commit
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: Nightly iOS Build Bump
branch: main
commit_user_name: github-actions[bot]
commit_user_email: github-actions[bot]@users.noreply.github.com
+3 -8
View File
@@ -18,9 +18,10 @@ describe('Create account', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2') await element(by.id('passwordInput')).typeText('hunter2')
@@ -33,12 +34,6 @@ describe('Create account', () => {
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible() await expect(element(by.id('onboardingInterests'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
}) })
}) })
+4 -4
View File
@@ -36,7 +36,7 @@ describe('Home screen', () => {
const carlaPosts = by.id('feedItem-by-carla.test') const carlaPosts = by.id('feedItem-by-carla.test')
await expect( await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
@@ -44,14 +44,14 @@ describe('Home screen', () => {
await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('likeCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can repost posts', async () => { it('Can repost posts', async () => {
const carlaPosts = by.id('feedItem-by-carla.test') const carlaPosts = by.id('feedItem-by-carla.test')
await expect( await expect(
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap() await element(by.id('repostBtn').withAncestor(carlaPosts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible() await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap() await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
@@ -65,7 +65,7 @@ describe('Home screen', () => {
await expect(element(by.id('repostModal'))).not.toBeVisible() await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect( await expect(
element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0), element(by.id('repostCount').withAncestor(carlaPosts)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can report posts', async () => { it('Can report posts', async () => {
+3 -8
View File
@@ -28,9 +28,10 @@ describe('invite-codes', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('inviteCodeInput')).typeText(inviteCode) await element(by.id('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('emailInput')).typeText('example@test.com')
@@ -40,12 +41,6 @@ describe('invite-codes', () => {
await element(by.id('handleInput')).typeText('e2e-test') await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle') await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible() await expect(element(by.id('onboardingInterests'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
}) })
}) })
@@ -28,16 +28,17 @@ describe('invite-codes', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('inviteCodeInput')).typeText(inviteCode) await element(by.id('inviteCodeInput')).typeText(inviteCode)
await element(by.id('emailInput')).typeText('example@test.com') await element(by.id('emailInput')).typeText('example@test.com')
await element(by.id('passwordInput')).typeText('hunter2') await element(by.id('passwordInput')).typeText('hunter2')
await device.takeScreenshot('4- entered account details') await device.takeScreenshot('4- entered account details')
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await element(by.id('phoneInput')).typeText('5558675309') await element(by.id('phoneInput')).typeText('2345551234')
await element(by.id('requestCodeBtn')).tap() await element(by.id('requestCodeBtn')).tap()
await device.takeScreenshot('5- requested code') await device.takeScreenshot('5- requested code')
await element(by.id('codeInput')).typeText('000000') await element(by.id('codeInput')).typeText('000000')
@@ -46,12 +47,6 @@ describe('invite-codes', () => {
await element(by.id('handleInput')).typeText('e2e-test') await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('7- entered handle') await device.takeScreenshot('7- entered handle')
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible() await expect(element(by.id('onboardingInterests'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
}) })
}) })
+33 -32
View File
@@ -2,7 +2,7 @@
import {describe, beforeAll, it} from '@jest/globals' import {describe, beforeAll, it} from '@jest/globals'
import {expect} from 'detox' import {expect} from 'detox'
import {openApp, loginAsAlice, loginAsBob, createServer, sleep} from '../util' import {openApp, loginAsAlice, loginAsBob, createServer} from '../util'
describe('Mod lists', () => { describe('Mod lists', () => {
beforeAll(async () => { beforeAll(async () => {
@@ -62,38 +62,39 @@ describe('Mod lists', () => {
.withTimeout(5000) .withTimeout(5000)
}) })
it('Set avi via the edit modlist modal', async () => { // DISABLED e2e environment is real finicky about avatar uploads -prf
await expect(element(by.id('userAvatarFallback'))).toExist() // it('Set avi via the edit modlist modal', async () => {
await element(by.id('headerDropdownBtn')).tap() // await expect(element(by.id('userAvatarFallback'))).toExist()
await element(by.text('Edit list details')).tap() // await element(by.id('headerDropdownBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible() // await element(by.text('Edit list details')).tap()
await element(by.id('changeAvatarBtn')).tap() // await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.text('Library')).tap() // await element(by.id('changeAvatarBtn')).tap()
await sleep(3e3) // await element(by.text('Library')).tap()
await element(by.id('saveBtn')).tap() // await sleep(3e3)
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() // await element(by.id('saveBtn')).tap()
await expect(element(by.id('userAvatarImage'))).toExist() // await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
// have to wait for the toast to clear // await expect(element(by.id('userAvatarImage'))).toExist()
await waitFor(element(by.id('headerDropdownBtn'))) // // have to wait for the toast to clear
.toBeVisible() // await waitFor(element(by.id('headerDropdownBtn')))
.withTimeout(5000) // .toBeVisible()
}) // .withTimeout(5000)
// })
it('Remove avi via the edit modlist modal', async () => { // it('Remove avi via the edit modlist modal', async () => {
await expect(element(by.id('userAvatarImage'))).toExist() // await expect(element(by.id('userAvatarImage'))).toExist()
await element(by.id('headerDropdownBtn')).tap() // await element(by.id('headerDropdownBtn')).tap()
await element(by.text('Edit list details')).tap() // await element(by.text('Edit list details')).tap()
await expect(element(by.id('createOrEditListModal'))).toBeVisible() // await expect(element(by.id('createOrEditListModal'))).toBeVisible()
await element(by.id('changeAvatarBtn')).tap() // await element(by.id('changeAvatarBtn')).tap()
await element(by.text('Remove')).tap() // await element(by.text('Remove')).tap()
await element(by.id('saveBtn')).tap() // await element(by.id('saveBtn')).tap()
await expect(element(by.id('createOrEditListModal'))).not.toBeVisible() // await expect(element(by.id('createOrEditListModal'))).not.toBeVisible()
await expect(element(by.id('userAvatarFallback'))).toExist() // await expect(element(by.id('userAvatarFallback'))).toExist()
// have to wait for the toast to clear // // have to wait for the toast to clear
await waitFor(element(by.id('headerDropdownBtn'))) // await waitFor(element(by.id('headerDropdownBtn')))
.toBeVisible() // .toBeVisible()
.withTimeout(5000) // .withTimeout(5000)
}) // })
it('Delete the modlist', async () => { it('Delete the modlist', async () => {
await element(by.id('headerDropdownBtn')).tap() await element(by.id('headerDropdownBtn')).tap()
+4 -4
View File
@@ -147,7 +147,7 @@ describe('Profile screen', () => {
const posts = by.id('feedItem-by-bob.test') const posts = by.id('feedItem-by-bob.test')
await expect( await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0), element(by.id('likeCount').withAncestor(posts)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0), element(by.id('likeCount').withAncestor(posts)).atIndex(0),
@@ -155,14 +155,14 @@ describe('Profile screen', () => {
await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(posts)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(posts)).atIndex(0), element(by.id('likeCount').withAncestor(posts)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can repost posts', async () => { it('Can repost posts', async () => {
const posts = by.id('feedItem-by-bob.test') const posts = by.id('feedItem-by-bob.test')
await expect( await expect(
element(by.id('repostCount').withAncestor(posts)).atIndex(0), element(by.id('repostCount').withAncestor(posts)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap() await element(by.id('repostBtn').withAncestor(posts)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible() await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap() await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
@@ -176,7 +176,7 @@ describe('Profile screen', () => {
await expect(element(by.id('repostModal'))).not.toBeVisible() await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect( await expect(
element(by.id('repostCount').withAncestor(posts)).atIndex(0), element(by.id('repostCount').withAncestor(posts)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can report posts', async () => { it('Can report posts', async () => {
+1
View File
@@ -28,6 +28,7 @@ describe('Self-labeling', () => {
await element(by.id('composerPublishBtn')).tap() await element(by.id('composerPublishBtn')).tap()
await expect(element(by.id('composeFAB'))).toBeVisible() await expect(element(by.id('composeFAB'))).toBeVisible()
const posts = by.id('feedItem-by-alice.test') const posts = by.id('feedItem-by-alice.test')
await element(by.id('e2eRefreshHome')).tap()
await expect( await expect(
element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0), element(by.id('contentHider-embed').withAncestor(posts)).atIndex(0),
).toExist() ).toExist()
+5 -9
View File
@@ -18,9 +18,10 @@ describe('Create account', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification@test.com') await element(by.id('emailInput')).typeText('text-verification@test.com')
await element(by.id('passwordInput')).typeText('hunter2') await element(by.id('passwordInput')).typeText('hunter2')
@@ -40,13 +41,7 @@ describe('Create account', () => {
await element(by.id('nextBtn')).tap() await element(by.id('nextBtn')).tap()
await expect(element(by.id('welcomeOnboarding'))).toBeVisible() await expect(element(by.id('onboardingInterests'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFeedsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('recommendedFollowsOnboarding'))).toBeVisible()
await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
}) })
it('failed text verification correctly goes back to the code input screen', async () => { it('failed text verification correctly goes back to the code input screen', async () => {
@@ -57,9 +52,10 @@ describe('Create account', () => {
await device.takeScreenshot('1- opened create account screen') await device.takeScreenshot('1- opened create account screen')
await element(by.id('selectServiceButton')).tap() await element(by.id('selectServiceButton')).tap()
await device.takeScreenshot('2- selected other server') await device.takeScreenshot('2- selected other server')
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
await device.takeScreenshot('3- input test server URL') await device.takeScreenshot('3- input test server URL')
await element(by.id('emailInput')).typeText('text-verification2@test.com') await element(by.id('emailInput')).typeText('text-verification2@test.com')
await element(by.id('passwordInput')).typeText('hunter2') await element(by.id('passwordInput')).typeText('hunter2')
+4 -4
View File
@@ -49,7 +49,7 @@ describe('Thread screen', () => {
const post = by.id('postThreadItem-by-carla.test') const post = by.id('postThreadItem-by-carla.test')
await expect( await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0), element(by.id('likeCount').withAncestor(post)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0), element(by.id('likeCount').withAncestor(post)).atIndex(0),
@@ -57,7 +57,7 @@ describe('Thread screen', () => {
await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap() await element(by.id('likeBtn').withAncestor(post)).atIndex(0).tap()
await expect( await expect(
element(by.id('likeCount').withAncestor(post)).atIndex(0), element(by.id('likeCount').withAncestor(post)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can repost the root post', async () => { it('Can repost the root post', async () => {
@@ -85,7 +85,7 @@ describe('Thread screen', () => {
const post = by.id('postThreadItem-by-carla.test') const post = by.id('postThreadItem-by-carla.test')
await expect( await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0), element(by.id('repostCount').withAncestor(post)).atIndex(0),
).toHaveText('0') ).not.toExist()
await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap() await element(by.id('repostBtn').withAncestor(post)).atIndex(0).tap()
await expect(element(by.id('repostModal'))).toBeVisible() await expect(element(by.id('repostModal'))).toBeVisible()
await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap() await element(by.id('repostBtn').withAncestor(by.id('repostModal'))).tap()
@@ -99,7 +99,7 @@ describe('Thread screen', () => {
await expect(element(by.id('repostModal'))).not.toBeVisible() await expect(element(by.id('repostModal'))).not.toBeVisible()
await expect( await expect(
element(by.id('repostCount').withAncestor(post)).atIndex(0), element(by.id('repostCount').withAncestor(post)).atIndex(0),
).toHaveText('0') ).not.toExist()
}) })
it('Can report the root post', async () => { it('Can report the root post', async () => {
+2 -1
View File
@@ -56,9 +56,10 @@ export async function login(
if (takeScreenshots) { if (takeScreenshots) {
await device.takeScreenshot('2- opened service selector') await device.takeScreenshot('2- opened service selector')
} }
await element(by.id('customSelectBtn')).tap()
await element(by.id('customServerTextInput')).typeText(service) await element(by.id('customServerTextInput')).typeText(service)
await element(by.id('customServerTextInput')).tapReturnKey() await element(by.id('customServerTextInput')).tapReturnKey()
await element(by.id('customServerSelectBtn')).tap() await element(by.id('doneBtn')).tap()
if (takeScreenshots) { if (takeScreenshots) {
await device.takeScreenshot('3- input custom service') await device.takeScreenshot('3- input custom service')
} }
+4 -19
View File
@@ -11,24 +11,12 @@ const DARK_SPLASH_CONFIG = {
resizeMode: 'cover', resizeMode: 'cover',
} }
module.exports = function () { module.exports = function (config) {
/** /**
* App version number. Should be incremented as part of a release cycle. * App version number. Should be incremented as part of a release cycle.
*/ */
const VERSION = pkg.version const VERSION = pkg.version
/**
* iOS build number. Must be incremented for each TestFlight version.
* WARNING: Always leave this variable on line 24! If it is moved, you need to update ./scripts/bumpIosBuildNumber.sh
*/
const IOS_BUILD_NUMBER = '3'
/**
* Android build number. Must be incremented for each release.
* WARNING: Always leave this variable on line 30! If it is moved, you need to update ./scripts/bumpAndroidBuildNumber.sh
*/
const ANDROID_VERSION_CODE = 61
/** /**
* Uses built-in Expo env vars * Uses built-in Expo env vars
* *
@@ -36,11 +24,10 @@ module.exports = function () {
*/ */
const PLATFORM = process.env.EAS_BUILD_PLATFORM const PLATFORM = process.env.EAS_BUILD_PLATFORM
/**
* Additional granularity for the `dist` field
*/
const DIST_BUILD_NUMBER = const DIST_BUILD_NUMBER =
PLATFORM === 'android' ? ANDROID_VERSION_CODE : IOS_BUILD_NUMBER PLATFORM === 'android'
? process.env.BSKY_ANDROID_VERSION_CODE
: process.env.BSKY_IOS_BUILD_NUMBER
return { return {
expo: { expo: {
@@ -57,7 +44,6 @@ module.exports = function () {
userInterfaceStyle: 'automatic', userInterfaceStyle: 'automatic',
splash: SPLASH_CONFIG, splash: SPLASH_CONFIG,
ios: { ios: {
buildNumber: IOS_BUILD_NUMBER,
supportsTablet: false, supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app', bundleIdentifier: 'xyz.blueskyweb.app',
config: { config: {
@@ -85,7 +71,6 @@ module.exports = function () {
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
}, },
android: { android: {
versionCode: ANDROID_VERSION_CODE,
icon: './assets/icon.png', icon: './assets/icon.png',
adaptiveIcon: { adaptiveIcon: {
foregroundImage: './assets/icon-android-foreground.png', foregroundImage: './assets/icon-android-foreground.png',
+1 -1
View File
@@ -191,7 +191,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings", server.WebGeneric) e.GET("/settings", server.WebGeneric)
e.GET("/settings/language", server.WebGeneric) e.GET("/settings/language", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric) e.GET("/settings/app-passwords", server.WebGeneric)
e.GET("/settings/home-feed", server.WebGeneric) e.GET("/settings/following-feed", server.WebGeneric)
e.GET("/settings/saved-feeds", server.WebGeneric) e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric) e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric) e.GET("/settings/external-embeds", server.WebGeneric)
+7 -1
View File
@@ -4,6 +4,11 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
<meta name="referrer" content="origin-when-cross-origin"> <meta name="referrer" content="origin-when-cross-origin">
<!--
Preconnect to essential domains
-->
<link rel="preconnect" href="https://bsky.social">
<link rel="preconnect" href="https://bsky.network">
<title>{%- block head_title -%}Bluesky{%- endblock -%}</title> <title>{%- block head_title -%}Bluesky{%- endblock -%}</title>
<!-- Hello Humans! API docs at https://atproto.com --> <!-- Hello Humans! API docs at https://atproto.com -->
@@ -206,10 +211,11 @@
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png"> <link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe"> <link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color">
<meta name="application-name" content="Bluesky"> <meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb"> <meta name="generator" content="bskyweb">
<meta property="og:site_name" content="Bluesky Social" /> <meta property="og:site_name" content="Bluesky Social" />
<link type="application/activity+json" href="" />
{% block html_head_extra -%}{%- endblock %} {% block html_head_extra -%}{%- endblock %}
</head> </head>
+17 -2
View File
@@ -1,7 +1,8 @@
{ {
"cli": { "cli": {
"version": ">= 3.8.1", "version": ">= 3.8.1",
"promptToConfigurePushNotifications": false "promptToConfigurePushNotifications": false,
"appVersionSource": "remote"
}, },
"build": { "build": {
"base": { "base": {
@@ -28,7 +29,21 @@
"production": { "production": {
"extends": "base", "extends": "base",
"ios": { "ios": {
"resourceClass": "large" "resourceClass": "large",
"autoIncrement": true
},
"android": {
"autoIncrement": true
},
"channel": "production"
},
"github": {
"extends": "base",
"ios": {
"autoIncrement": true
},
"android": {
"autoIncrement": true
}, },
"channel": "production" "channel": "production"
} }
+1
View File
@@ -13,6 +13,7 @@ module.exports = {
'uk', 'uk',
'ca', 'ca',
'zh-CN', 'zh-CN',
'it',
], ],
catalogs: [ catalogs: [
{ {
@@ -4,6 +4,7 @@
RCT_REMAP_SHADOW_PROPERTY(numberOfLines, numberOfLines, NSInteger) RCT_REMAP_SHADOW_PROPERTY(numberOfLines, numberOfLines, NSInteger)
RCT_REMAP_SHADOW_PROPERTY(allowsFontScaling, allowsFontScaling, BOOL) RCT_REMAP_SHADOW_PROPERTY(allowsFontScaling, allowsFontScaling, BOOL)
RCT_EXPORT_VIEW_PROPERTY(numberOfLines, NSInteger)
RCT_EXPORT_VIEW_PROPERTY(onTextLayout, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onTextLayout, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(ellipsizeMode, NSString) RCT_EXPORT_VIEW_PROPERTY(ellipsizeMode, NSString)
RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL) RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)
@@ -40,19 +40,19 @@ class RNUITextViewShadow: RCTShadowView {
self.setAttributedText() self.setAttributedText()
} }
// Tell yoga not to use flexbox // Returning true here will tell Yoga to not use flexbox and instead use our custom measure func.
override func isYogaLeafNode() -> Bool { override func isYogaLeafNode() -> Bool {
return true return true
} }
// We only need to insert text children // We should only insert children that are UITextView shadows
override func insertReactSubview(_ subview: RCTShadowView!, at atIndex: Int) { override func insertReactSubview(_ subview: RCTShadowView!, at atIndex: Int) {
if subview.isKind(of: RNUITextViewChildShadow.self) { if subview.isKind(of: RNUITextViewChildShadow.self) {
super.insertReactSubview(subview, at: atIndex) super.insertReactSubview(subview, at: atIndex)
} }
} }
// Whenever the subvies update, set the text // Every time the subviews change, we need to reformat and render the text.
override func didUpdateReactSubviews() { override func didUpdateReactSubviews() {
self.setAttributedText() self.setAttributedText()
} }
@@ -64,7 +64,7 @@ class RNUITextViewShadow: RCTShadowView {
return return
} }
// Update the text // Since we are inside the shadow view here, we have to find the real view and update the text.
self.bridge.uiManager.addUIBlock { uiManager, viewRegistry in self.bridge.uiManager.addUIBlock { uiManager, viewRegistry in
guard let textView = viewRegistry?[self.reactTag] as? RNUITextView else { guard let textView = viewRegistry?[self.reactTag] as? RNUITextView else {
return return
@@ -100,18 +100,25 @@ class RNUITextViewShadow: RCTShadowView {
// Create the attributed string with the generic attributes // Create the attributed string with the generic attributes
let string = NSMutableAttributedString(string: child.text, attributes: attributes) let string = NSMutableAttributedString(string: child.text, attributes: attributes)
// Set the paragraph style attributes if necessary // Set the paragraph style attributes if necessary. We can check this by seeing if the provided
// line height is not 0.0.
let paragraphStyle = NSMutableParagraphStyle() let paragraphStyle = NSMutableParagraphStyle()
if child.lineHeight != 0.0 { if child.lineHeight != 0.0 {
paragraphStyle.minimumLineHeight = child.lineHeight // Whenever we change the line height for the text, we are also removing the DynamicType
paragraphStyle.maximumLineHeight = child.lineHeight // adjustment for line height. We need to get the multiplier and apply that to the
// line height.
let scaleMultiplier = scaledFontSize / child.fontSize
paragraphStyle.minimumLineHeight = child.lineHeight * scaleMultiplier
paragraphStyle.maximumLineHeight = child.lineHeight * scaleMultiplier
string.addAttribute( string.addAttribute(
NSAttributedString.Key.paragraphStyle, NSAttributedString.Key.paragraphStyle,
value: paragraphStyle, value: paragraphStyle,
range: NSMakeRange(0, string.length) range: NSMakeRange(0, string.length)
) )
// Store that height // To calcualte the size of the text without creating a new UILabel or UITextView, we have
// to store this line height for later.
self.lineHeight = child.lineHeight self.lineHeight = child.lineHeight
} else { } else {
self.lineHeight = font.lineHeight self.lineHeight = font.lineHeight
@@ -124,24 +131,22 @@ class RNUITextViewShadow: RCTShadowView {
self.dirtyLayout() self.dirtyLayout()
} }
// Create a YGSize based on the max width // To create the needed size we need to:
// 1. Get the max size that we can use for the view
// 2. Calculate the height of the text based on that max size
// 3. Determine how many lines the text is, and limit that number if it exceeds the max
// 4. Set the frame size and return the YGSize. YGSize requires Float values while CGSize needs CGFloat
func getNeededSize(maxWidth: Float) -> YGSize { func getNeededSize(maxWidth: Float) -> YGSize {
// Create the max size and figure out the size of the entire text
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT)) let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil) let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
// Figure out how many total lines there are var totalLines = Int(ceil(textSize.height / self.lineHeight))
let totalLines = Int(ceil(textSize.height / self.lineHeight))
// Default to the text size
var neededSize: CGSize = textSize.size
// If the total lines > max number, return size with the max
if self.numberOfLines != 0, totalLines > self.numberOfLines { if self.numberOfLines != 0, totalLines > self.numberOfLines {
neededSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(self.numberOfLines) * self.lineHeight)) totalLines = self.numberOfLines
} }
self.frameSize = neededSize self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
return YGSize(width: Float(neededSize.width), height: Float(neededSize.height)) return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
} }
} }
+10 -10
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.68.0", "version": "1.69.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -12,8 +12,12 @@
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"use-build-number": "./scripts/useBuildNumberEnv.sh",
"build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/", "build-web": "expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
"build-all": "yarn intl:build && eas build --platform all", "build-all": "yarn intl:build && yarn use-build-number eas build --platform all",
"build-ios": "yarn use-build-number eas build -p ios",
"build-android": "yarn use-build-number eas build -p android",
"build": "yarn use-build-number eas build",
"start": "expo start --dev-client", "start": "expo start --dev-client",
"start:prod": "expo start --dev-client --no-dev --minify", "start:prod": "expo start --dev-client --no-dev --minify",
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*", "clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
@@ -36,10 +40,7 @@
"intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)", "intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)",
"intl:extract": "lingui extract", "intl:extract": "lingui extract",
"intl:compile": "lingui compile", "intl:compile": "lingui compile",
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android", "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
"bump": "./scripts/bumpIosBuildNumber.sh && ./scripts/bumpAndroidBuildNumber.sh",
"bump:ios": "./scripts/bumpIosBuildNumber.sh",
"bump:android": "./scripts/bumpAndroidBuildNumber.sh"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.9.5", "@atproto/api": "^0.9.5",
@@ -123,7 +124,6 @@
"js-sha256": "^0.9.0", "js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lande": "^1.0.10", "lande": "^1.0.10",
"libphonenumber-js": "^1.10.53",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
@@ -137,7 +137,7 @@
"mobx": "^6.6.1", "mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0", "mobx-react-lite": "^3.4.0",
"mobx-utils": "^6.0.6", "mobx-utils": "^6.0.6",
"nanoid": "^5.0.2", "nanoid": "^5.0.5",
"normalize-url": "^8.0.0", "normalize-url": "^8.0.0",
"patch-package": "^6.5.1", "patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
@@ -164,6 +164,7 @@
"react-native-safe-area-context": "4.8.2", "react-native-safe-area-context": "4.8.2",
"react-native-screens": "~3.29.0", "react-native-screens": "~3.29.0",
"react-native-svg": "14.1.0", "react-native-svg": "14.1.0",
"react-native-ui-text-view": "link:./modules/react-native-ui-text-view",
"react-native-url-polyfill": "^1.3.0", "react-native-url-polyfill": "^1.3.0",
"react-native-uuid": "^2.0.1", "react-native-uuid": "^2.0.1",
"react-native-version-number": "^0.3.6", "react-native-version-number": "^0.3.6",
@@ -178,8 +179,7 @@
"tlds": "^1.234.0", "tlds": "^1.234.0",
"use-deep-compare": "^1.1.0", "use-deep-compare": "^1.1.0",
"zeego": "^1.6.2", "zeego": "^1.6.2",
"zod": "^3.20.2", "zod": "^3.20.2"
"react-native-ui-text-view": "link:./modules/react-native-ui-text-view"
}, },
"devDependencies": { "devDependencies": {
"@atproto/dev-env": "^0.2.28", "@atproto/dev-env": "^0.2.28",
@@ -0,0 +1,56 @@
diff --git a/node_modules/@react-navigation/native/lib/commonjs/useLinking.js b/node_modules/@react-navigation/native/lib/commonjs/useLinking.js
index ef4f368..2b0da35 100644
--- a/node_modules/@react-navigation/native/lib/commonjs/useLinking.js
+++ b/node_modules/@react-navigation/native/lib/commonjs/useLinking.js
@@ -273,8 +273,12 @@ function useLinking(ref, _ref) {
});
const currentIndex = history.index;
try {
- if (nextIndex !== -1 && nextIndex < currentIndex) {
- // An existing entry for this path exists and it's less than current index, go back to that
+ if (
+ nextIndex !== -1 &&
+ nextIndex < currentIndex &&
+ // We should only go back if the entry exists and it's less than current index
+ history.get(nextIndex - currentIndex)
+ ) { // An existing entry for this path exists and it's less than current index, go back to that
await history.go(nextIndex - currentIndex);
} else {
// We couldn't find an existing entry to go back to, so we'll go back by the delta
diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js
index 62a3b43..11a5a28 100644
--- a/node_modules/@react-navigation/native/lib/module/useLinking.js
+++ b/node_modules/@react-navigation/native/lib/module/useLinking.js
@@ -264,8 +264,12 @@ export default function useLinking(ref, _ref) {
});
const currentIndex = history.index;
try {
- if (nextIndex !== -1 && nextIndex < currentIndex) {
- // An existing entry for this path exists and it's less than current index, go back to that
+ if (
+ nextIndex !== -1 &&
+ nextIndex < currentIndex &&
+ // We should only go back if the entry exists and it's less than current index
+ history.get(nextIndex - currentIndex)
+ ) { // An existing entry for this path exists and it's less than current index, go back to that
await history.go(nextIndex - currentIndex);
} else {
// We couldn't find an existing entry to go back to, so we'll go back by the delta
diff --git a/node_modules/@react-navigation/native/src/useLinking.tsx b/node_modules/@react-navigation/native/src/useLinking.tsx
index 3db40b7..9ba4ecd 100644
--- a/node_modules/@react-navigation/native/src/useLinking.tsx
+++ b/node_modules/@react-navigation/native/src/useLinking.tsx
@@ -381,7 +381,12 @@ export default function useLinking(
const currentIndex = history.index;
try {
- if (nextIndex !== -1 && nextIndex < currentIndex) {
+ if (
+ nextIndex !== -1 &&
+ nextIndex < currentIndex &&
+ // We should only go back if the entry exists and it's less than current index
+ history.get(nextIndex - currentIndex)
+ ) {
// An existing entry for this path exists and it's less than current index, go back to that
await history.go(nextIndex - currentIndex);
} else {
@@ -0,0 +1,5 @@
# React Navigation history bug patch
This patches react-navigation to fix the issues in https://github.com/bluesky-social/social-app/issues/710.
This is based on the PR found at https://github.com/react-navigation/react-navigation/pull/11833
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
# The number here should always be the line number the iOS build variable is on
line=$(sed "30q;d" ./app.config.js)
currentBuildNumber=$(echo "$line" | grep -oE '[0-9]+([.][0-9]+)?')
newBuildNumber=$((currentBuildNumber+1))
newBuildVariable="const ANDROID_VERSION_CODE = '$newBuildNumber'"
sed -i.bak "30s/.*/ $newBuildVariable/" ./app.config.js
rm -rf ./app.config.js.bak
echo "Android build number bumped to $newBuildNumber"
-10
View File
@@ -1,10 +0,0 @@
#!/bin/sh
# The number here should always be the line number the iOS build variable is on
line=$(sed "24q;d" ./app.config.js)
currentBuildNumber=$(echo "$line" | grep -oE '[0-9]+([.][0-9]+)?')
newBuildNumber=$((currentBuildNumber+1))
newBuildVariable="const IOS_BUILD_NUMBER = '$newBuildNumber'"
sed -i.bak "24s/.*/ $newBuildVariable/" ./app.config.js
rm -rf ./app.config.js.bak
echo "iOS build number bumped to $newBuildNumber"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
outputIos=$(eas build:version:get -p ios)
outputAndroid=$(eas build:version:get -p android)
currentIosVersion=${outputIos#*buildNumber - }
currentAndroidVersion=${outputAndroid#*versionCode - }
BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1))
BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1))
bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*"
+7 -4
View File
@@ -72,7 +72,7 @@ import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds' import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed' import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads' import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
@@ -248,9 +248,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
options={{title: title(msg`Edit My Feeds`), requireAuth: true}} options={{title: title(msg`Edit My Feeds`), requireAuth: true}}
/> />
<Stack.Screen <Stack.Screen
name="PreferencesHomeFeed" name="PreferencesFollowingFeed"
getComponent={() => PreferencesHomeFeed} getComponent={() => PreferencesFollowingFeed}
options={{title: title(msg`Home Feed Preferences`), requireAuth: true}} options={{
title: title(msg`Following Feed Preferences`),
requireAuth: true,
}}
/> />
<Stack.Screen <Stack.Screen
name="PreferencesThreads" name="PreferencesThreads"
+7 -5
View File
@@ -21,7 +21,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Svg, {Path, SvgProps} from 'react-native-svg' import Svg, {Path, SvgProps} from 'react-native-svg'
import {isAndroid} from '#/platform/detection' import {isAndroid} from '#/platform/detection'
import {useThemePrefs} from 'state/shell'
import {Logotype} from '#/view/icons/Logotype' import {Logotype} from '#/view/icons/Logotype'
// @ts-ignore // @ts-ignore
@@ -75,10 +74,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
isLayoutReady && isLayoutReady &&
reduceMotion !== undefined reduceMotion !== undefined
const {colorMode} = useThemePrefs()
const colorScheme = useColorScheme() const colorScheme = useColorScheme()
const themeName = colorMode === 'system' ? colorScheme : colorMode const isDarkMode = colorScheme === 'dark'
const isDarkMode = themeName === 'dark'
const logoAnimation = useAnimatedStyle(() => { const logoAnimation = useAnimatedStyle(() => {
return { return {
@@ -263,7 +260,12 @@ export function Splash(props: React.PropsWithChildren<Props>) {
<View <View
style={[ style={[
StyleSheet.absoluteFillObject, StyleSheet.absoluteFillObject,
{backgroundColor: '#fff'}, {
backgroundColor: isDarkMode
? // special off-spec color for dark mode
'#0F1824'
: '#fff',
},
]} ]}
/> />
)} )}
+17 -1
View File
@@ -176,43 +176,59 @@ export const atoms = {
}, },
text_2xs: { text_2xs: {
fontSize: tokens.fontSize._2xs, fontSize: tokens.fontSize._2xs,
letterSpacing: 0.25,
}, },
text_xs: { text_xs: {
fontSize: tokens.fontSize.xs, fontSize: tokens.fontSize.xs,
letterSpacing: 0.25,
}, },
text_sm: { text_sm: {
fontSize: tokens.fontSize.sm, fontSize: tokens.fontSize.sm,
letterSpacing: 0.25,
}, },
text_md: { text_md: {
fontSize: tokens.fontSize.md, fontSize: tokens.fontSize.md,
letterSpacing: 0.25,
}, },
text_lg: { text_lg: {
fontSize: tokens.fontSize.lg, fontSize: tokens.fontSize.lg,
letterSpacing: 0.25,
}, },
text_xl: { text_xl: {
fontSize: tokens.fontSize.xl, fontSize: tokens.fontSize.xl,
letterSpacing: 0.25,
}, },
text_2xl: { text_2xl: {
fontSize: tokens.fontSize._2xl, fontSize: tokens.fontSize._2xl,
letterSpacing: 0.25,
}, },
text_3xl: { text_3xl: {
fontSize: tokens.fontSize._3xl, fontSize: tokens.fontSize._3xl,
letterSpacing: 0.25,
}, },
text_4xl: { text_4xl: {
fontSize: tokens.fontSize._4xl, fontSize: tokens.fontSize._4xl,
letterSpacing: 0.25,
}, },
text_5xl: { text_5xl: {
fontSize: tokens.fontSize._5xl, fontSize: tokens.fontSize._5xl,
letterSpacing: 0.25,
}, },
leading_tight: { leading_tight: {
lineHeight: 1.15, lineHeight: 1.15,
}, },
leading_snug: { leading_snug: {
lineHeight: 1.25, lineHeight: 1.3,
}, },
leading_normal: { leading_normal: {
lineHeight: 1.5, lineHeight: 1.5,
}, },
tracking_normal: {
letterSpacing: 0,
},
tracking_wide: {
letterSpacing: 0.25,
},
font_normal: { font_normal: {
fontWeight: tokens.fontWeight.normal, fontWeight: tokens.fontWeight.normal,
}, },
+50 -27
View File
@@ -73,19 +73,19 @@ export const darkPalette: Palette = {
white: tokens.color.gray_0, white: tokens.color.gray_0,
black: tokens.color.trueBlack, black: tokens.color.trueBlack,
contrast_25: tokens.color.gray_1000, contrast_25: `hsl(211, 28%, 8%)`,
contrast_50: tokens.color.gray_975, contrast_50: `hsl(211, 28%, 11%)`,
contrast_100: tokens.color.gray_950, contrast_100: `hsl(211, 28%, 16%)`,
contrast_200: tokens.color.gray_900, contrast_200: `hsl(211, 28%, 24%)`,
contrast_300: tokens.color.gray_800, contrast_300: `hsl(211, 24%, 31%)`,
contrast_400: tokens.color.gray_700, contrast_400: `hsl(211, 24%, 38%)`,
contrast_500: tokens.color.gray_600, contrast_500: `hsl(211, 20%, 44%)`,
contrast_600: tokens.color.gray_500, contrast_600: `hsl(211, 20%, 55%)`,
contrast_700: tokens.color.gray_400, contrast_700: `hsl(211, 20%, 63%)`,
contrast_800: tokens.color.gray_300, contrast_800: `hsl(211, 20%, 71%)`,
contrast_900: tokens.color.gray_200, contrast_900: `hsl(211, 20%, 79%)`,
contrast_950: tokens.color.gray_100, contrast_950: `hsl(211, 20%, 87%)`,
contrast_975: tokens.color.gray_50, contrast_975: `hsl(211, 20%, 95%)`,
primary_25: tokens.color.blue_25, primary_25: tokens.color.blue_25,
primary_50: tokens.color.blue_50, primary_50: tokens.color.blue_50,
@@ -132,21 +132,28 @@ export const darkPalette: Palette = {
export const dimPalette: Palette = { export const dimPalette: Palette = {
...darkPalette, ...darkPalette,
black: tokens.color.gray_1000, black: `hsl(211, 28%, 12%)`,
contrast_25: tokens.color.gray_975, contrast_25: `hsl(211, 28%, 15%)`,
contrast_50: tokens.color.gray_950, contrast_50: `hsl(211, 28%, 18%)`,
contrast_100: tokens.color.gray_900, contrast_100: `hsl(211, 28%, 24%)`,
contrast_200: tokens.color.gray_800, contrast_200: `hsl(211, 28%, 27%)`,
contrast_300: tokens.color.gray_700, contrast_300: `hsl(211, 24%, 34%)`,
contrast_400: tokens.color.gray_600, contrast_400: `hsl(211, 24%, 41%)`,
contrast_500: tokens.color.gray_500, contrast_500: `hsl(211, 20%, 52%)`,
contrast_600: tokens.color.gray_400, contrast_600: `hsl(211, 20%, 55%)`,
contrast_700: tokens.color.gray_300, contrast_700: `hsl(211, 20%, 67%)`,
contrast_800: tokens.color.gray_200, contrast_800: `hsl(211, 20%, 71%)`,
contrast_900: tokens.color.gray_100, contrast_900: `hsl(211, 20%, 79%)`,
contrast_950: tokens.color.gray_50, contrast_950: `hsl(211, 20%, 87%)`,
contrast_975: tokens.color.gray_25, contrast_975: `hsl(211, 20%, 95%)`,
primary_600: `hsl(211, 95%, 39%)`,
primary_700: `hsl(211, 90%, 30%)`,
primary_800: `hsl(211, 90%, 23%)`,
primary_900: `hsl(211, 80%, 16%)`,
primary_950: `hsl(211, 80%, 13%)`,
primary_975: `hsl(211, 80%, 10%)`,
} as const } as const
export const light = { export const light = {
@@ -325,6 +332,7 @@ export const dark: Theme = {
export const dim: Theme = { export const dim: Theme = {
...dark, ...dark,
name: 'dim', name: 'dim',
palette: dimPalette,
atoms: { atoms: {
...dark.atoms, ...dark.atoms,
text: { text: {
@@ -393,5 +401,20 @@ export const dim: Theme = {
border_contrast_high: { border_contrast_high: {
borderColor: dimPalette.contrast_300, borderColor: dimPalette.contrast_300,
}, },
shadow_sm: {
...atoms.shadow_sm,
shadowOpacity: 0.7,
shadowColor: `hsl(211, 28%, 3%)`,
},
shadow_md: {
...atoms.shadow_md,
shadowOpacity: 0.7,
shadowColor: `hsl(211, 28%, 3%)`,
},
shadow_lg: {
...atoms.shadow_lg,
shadowOpacity: 0.7,
shadowColor: `hsl(211, 28%, 3%)`,
},
}, },
} }
+10 -9
View File
@@ -13,7 +13,7 @@ export function useColorModeTheme(): ThemeName {
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const theme = getThemeName(colorScheme, colorMode, darkTheme) const theme = getThemeName(colorScheme, colorMode, darkTheme)
updateDocument(theme) updateDocument(theme)
updateSystemBackground(theme) SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
}, [colorMode, colorScheme, darkTheme]) }, [colorMode, colorScheme, darkTheme])
return React.useMemo( return React.useMemo(
@@ -42,23 +42,24 @@ function updateDocument(theme: ThemeName) {
if (isWeb && typeof window !== 'undefined') { if (isWeb && typeof window !== 'undefined') {
// @ts-ignore web only // @ts-ignore web only
const html = window.document.documentElement const html = window.document.documentElement
// @ts-ignore web only
const meta = window.document.querySelector('meta[name="theme-color"]')
// remove any other color mode classes // remove any other color mode classes
html.className = html.className.replace(/(theme)--\w+/g, '') html.className = html.className.replace(/(theme)--\w+/g, '')
html.classList.add(`theme--${theme}`) html.classList.add(`theme--${theme}`)
// set color to 'theme-color' meta tag
meta?.setAttribute('content', getBackgroundColor(theme))
} }
} }
function updateSystemBackground(theme: ThemeName) { function getBackgroundColor(theme: ThemeName): string {
switch (theme) { switch (theme) {
case 'light': case 'light':
SystemUI.setBackgroundColorAsync(light.atoms.bg.backgroundColor) return light.atoms.bg.backgroundColor
break
case 'dark': case 'dark':
SystemUI.setBackgroundColorAsync(dark.atoms.bg.backgroundColor) return dark.atoms.bg.backgroundColor
break
case 'dim': case 'dim':
SystemUI.setBackgroundColorAsync(dim.atoms.bg.backgroundColor) return dim.atoms.bg.backgroundColor
break
} }
} }
+4 -2
View File
@@ -55,6 +55,8 @@ export type ButtonState = {
disabled: boolean disabled: boolean
} }
export type ButtonContext = VariantProps & ButtonState
export type ButtonProps = Pick< export type ButtonProps = Pick<
PressableProps, PressableProps,
'disabled' | 'onPress' | 'testID' 'disabled' | 'onPress' | 'testID'
@@ -67,7 +69,7 @@ export type ButtonProps = Pick<
children: children:
| React.ReactNode | React.ReactNode
| string | string
| ((state: VariantProps & ButtonState) => React.ReactNode | string) | ((context: ButtonContext) => React.ReactNode | string)
} }
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
@@ -351,7 +353,7 @@ export function Button({
} }
}, [variant, color]) }, [variant, color])
const context = React.useMemo( const context = React.useMemo<ButtonContext>(
() => ({ () => ({
...state, ...state,
variant, variant,
+7 -3
View File
@@ -1,7 +1,11 @@
import React from 'react' import React from 'react'
import {useDialogStateContext} from '#/state/dialogs' import {useDialogStateContext} from '#/state/dialogs'
import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types' import {
DialogContextProps,
DialogControlProps,
DialogOuterProps,
} from '#/components/Dialog/types'
export const Context = React.createContext<DialogContextProps>({ export const Context = React.createContext<DialogContextProps>({
close: () => {}, close: () => {},
@@ -11,7 +15,7 @@ export function useDialogContext() {
return React.useContext(Context) return React.useContext(Context)
} }
export function useDialogControl() { export function useDialogControl(): DialogOuterProps['control'] {
const id = React.useId() const id = React.useId()
const control = React.useRef<DialogControlProps>({ const control = React.useRef<DialogControlProps>({
open: () => {}, open: () => {},
@@ -30,6 +34,6 @@ export function useDialogControl() {
return { return {
ref: control, ref: control,
open: () => control.current.open(), open: () => control.current.open(),
close: () => control.current.close(), close: cb => control.current.close(cb),
} }
} }
+78 -52
View File
@@ -8,7 +8,7 @@ import BottomSheet, {
} from '@gorhom/bottom-sheet' } from '@gorhom/bottom-sheet'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useTheme, atoms as a} from '#/alf' import {useTheme, atoms as a, flatten} from '#/alf'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {createInput} from '#/components/forms/TextField' import {createInput} from '#/components/forms/TextField'
@@ -29,19 +29,36 @@ export function Outer({
control, control,
onClose, onClose,
nativeOptions, nativeOptions,
defaultOpen,
}: React.PropsWithChildren<DialogOuterProps>) { }: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme() const t = useTheme()
const sheet = React.useRef<BottomSheet>(null) const sheet = React.useRef<BottomSheet>(null)
const sheetOptions = nativeOptions?.sheet || {} const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const closeCallback = React.useRef<() => void>()
const open = React.useCallback<DialogControlProps['open']>(({index} = {}) => { /*
sheet.current?.snapToIndex(index || 0) * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
}, []) */
const [openIndex, setOpenIndex] = React.useState(-1)
const close = React.useCallback(() => { /*
* `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
*/
const isOpen = openIndex > -1
const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => {
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0)
},
[setOpenIndex],
)
const close = React.useCallback<DialogControlProps['close']>(cb => {
if (cb) {
closeCallback.current = cb
}
sheet.current?.close() sheet.current?.close()
}, []) }, [])
@@ -57,60 +74,66 @@ export function Outer({
const onChange = React.useCallback( const onChange = React.useCallback(
(index: number) => { (index: number) => {
if (index === -1) { if (index === -1) {
closeCallback.current?.()
closeCallback.current = undefined
onClose?.() onClose?.()
setOpenIndex(-1)
} }
}, },
[onClose], [onClose, setOpenIndex],
) )
const context = React.useMemo(() => ({close}), [close]) const context = React.useMemo(() => ({close}), [close])
return ( return (
<Portal> isOpen && (
<BottomSheet <Portal>
enableDynamicSizing={!hasSnapPoints} <BottomSheet
enablePanDownToClose enableDynamicSizing={!hasSnapPoints}
keyboardBehavior="interactive" enablePanDownToClose
android_keyboardInputMode="adjustResize" keyboardBehavior="interactive"
keyboardBlurBehavior="restore" android_keyboardInputMode="adjustResize"
topInset={insets.top} keyboardBlurBehavior="restore"
{...sheetOptions} topInset={insets.top}
ref={sheet} {...sheetOptions}
index={defaultOpen ? 0 : -1} snapPoints={sheetOptions.snapPoints || ['100%']}
backgroundStyle={{backgroundColor: 'transparent'}} ref={sheet}
backdropComponent={props => ( index={openIndex}
<BottomSheetBackdrop backgroundStyle={{backgroundColor: 'transparent'}}
opacity={0.4} backdropComponent={props => (
appearsOnIndex={0} <BottomSheetBackdrop
disappearsOnIndex={-1} opacity={0.4}
{...props} appearsOnIndex={0}
/> disappearsOnIndex={-1}
)} {...props}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} style={[flatten(props.style), t.atoms.bg_contrast_300]}
handleStyle={{display: 'none'}} />
onChange={onChange}> )}
<Context.Provider value={context}> handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
<View handleStyle={{display: 'none'}}
style={[ onChange={onChange}>
a.absolute, <Context.Provider value={context}>
a.inset_0, <View
t.atoms.bg, style={[
{ a.absolute,
borderTopLeftRadius: 40, a.inset_0,
borderTopRightRadius: 40, t.atoms.bg,
height: Dimensions.get('window').height * 2, {
}, borderTopLeftRadius: 40,
]} borderTopRightRadius: 40,
/> height: Dimensions.get('window').height * 2,
{children} },
</Context.Provider> ]}
</BottomSheet> />
</Portal> {children}
</Context.Provider>
</BottomSheet>
</Portal>
)
) )
} }
// TODO a11y props here, or is that handled by the sheet? export function Inner({children, style}: DialogInnerProps) {
export function Inner(props: DialogInnerProps) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
return ( return (
<BottomSheetView <BottomSheetView
@@ -122,13 +145,14 @@ export function Inner(props: DialogInnerProps) {
borderTopRightRadius: 40, borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom, paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
}, },
flatten(style),
]}> ]}>
{props.children} {children}
</BottomSheetView> </BottomSheetView>
) )
} }
export function ScrollableInner(props: DialogInnerProps) { export function ScrollableInner({children, style}: DialogInnerProps) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
return ( return (
<BottomSheetScrollView <BottomSheetScrollView
@@ -137,13 +161,15 @@ export function ScrollableInner(props: DialogInnerProps) {
style={[ style={[
a.flex_1, // main diff is this a.flex_1, // main diff is this
a.p_xl, a.p_xl,
a.h_full,
{ {
paddingTop: 40, paddingTop: 40,
borderTopLeftRadius: 40, borderTopLeftRadius: 40,
borderTopRightRadius: 40, borderTopRightRadius: 40,
}, },
flatten(style),
]}> ]}>
{props.children} {children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
</BottomSheetScrollView> </BottomSheetScrollView>
) )
+3 -4
View File
@@ -5,7 +5,7 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useTheme, atoms as a, useBreakpoints, web} from '#/alf' import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types' import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
@@ -23,12 +23,11 @@ export function Outer({
children, children,
control, control,
onClose, onClose,
defaultOpen,
}: React.PropsWithChildren<DialogOuterProps>) { }: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(defaultOpen) const [isOpen, setIsOpen] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(true) const [isVisible, setIsVisible] = React.useState(true)
const open = React.useCallback(() => { const open = React.useCallback(() => {
@@ -159,7 +158,7 @@ export function Inner({
shadowOpacity: t.name === 'light' ? 0.1 : 0.4, shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
shadowRadius: 30, shadowRadius: 30,
}, },
...(Array.isArray(style) ? style : [style || {}]), flatten(style),
]}> ]}>
{children} {children}
</Animated.View> </Animated.View>
+15 -11
View File
@@ -1,27 +1,34 @@
import React from 'react' import React from 'react'
import type {ViewStyle, AccessibilityProps} from 'react-native' import type {AccessibilityProps} from 'react-native'
import {BottomSheetProps} from '@gorhom/bottom-sheet' import {BottomSheetProps} from '@gorhom/bottom-sheet'
import {ViewStyleProp} from '#/alf'
type A11yProps = Required<AccessibilityProps> type A11yProps = Required<AccessibilityProps>
export type DialogContextProps = { export type DialogContextProps = {
close: () => void close: () => void
} }
export type DialogControlOpenOptions = {index?: number} export type DialogControlOpenOptions = {
/**
* NATIVE ONLY
*
* Optional index of the snap point to open the bottom sheet to. Defaults to
* 0, which is the first snap point (i.e. "open").
*/
index?: number
}
export type DialogControlProps = { export type DialogControlProps = {
open: (options?: DialogControlOpenOptions) => void open: (options?: DialogControlOpenOptions) => void
close: () => void close: (callback?: () => void) => void
} }
export type DialogOuterProps = { export type DialogOuterProps = {
defaultOpen?: boolean
control: { control: {
ref: React.RefObject<DialogControlProps> ref: React.RefObject<DialogControlProps>
open: (index?: number) => void } & DialogControlProps
close: () => void
}
onClose?: () => void onClose?: () => void
nativeOptions?: { nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'> sheet?: Omit<BottomSheetProps, 'children'>
@@ -29,10 +36,7 @@ export type DialogOuterProps = {
webOptions?: {} webOptions?: {}
} }
type DialogInnerPropsBase<T> = React.PropsWithChildren<{ type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
style?: ViewStyle
}> &
T
export type DialogInnerProps = export type DialogInnerProps =
| DialogInnerPropsBase<{ | DialogInnerPropsBase<{
label?: undefined label?: undefined
@@ -30,8 +30,8 @@ export function IconCircle({
a.align_center, a.align_center,
a.rounded_full, a.rounded_full,
{ {
width: 64, width: size === 'lg' ? 52 : 64,
height: 64, height: size === 'lg' ? 52 : 64,
backgroundColor: backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950, t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
}, },
+79 -63
View File
@@ -1,9 +1,5 @@
import React from 'react' import React from 'react'
import { import {GestureResponderEvent} from 'react-native'
GestureResponderEvent,
Linking,
TouchableWithoutFeedback,
} from 'react-native'
import { import {
useLinkProps, useLinkProps,
useNavigation, useNavigation,
@@ -23,7 +19,8 @@ import {
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {router} from '#/routes' import {router} from '#/routes'
import {Text} from '#/components/Typography' import {Text, TextProps} from '#/components/Typography'
import {useOpenLink} from 'state/preferences/in-app-browser'
/** /**
* Only available within a `Link`, since that inherits from `Button`. * Only available within a `Link`, since that inherits from `Button`.
@@ -37,6 +34,11 @@ type BaseLinkProps = Pick<
> & { > & {
testID?: string testID?: string
/**
* Label for a11y. Defaults to the href.
*/
label?: string
/** /**
* The React Navigation `StackAction` to perform when the link is pressed. * The React Navigation `StackAction` to perform when the link is pressed.
*/ */
@@ -50,11 +52,17 @@ type BaseLinkProps = Pick<
warnOnMismatchingTextChild?: boolean warnOnMismatchingTextChild?: boolean
/** /**
* Callback for when the link is pressed. * Callback for when the link is pressed. Prevent default and return `false`
* to exit early and prevent navigation.
* *
* DO NOT use this for navigation, that's what the `to` prop is for. * DO NOT use this for navigation, that's what the `to` prop is for.
*/ */
onPress?: (e: GestureResponderEvent) => void onPress?: (e: GestureResponderEvent) => void | false
/**
* Web-only attribute. Sets `download` attr on web.
*/
download?: string
} }
export function useLink({ export function useLink({
@@ -73,10 +81,13 @@ export function useLink({
}) })
const isExternal = isExternalUrl(href) const isExternal = isExternalUrl(href)
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink()
const onPress = React.useCallback( const onPress = React.useCallback(
(e: GestureResponderEvent) => { (e: GestureResponderEvent) => {
outerOnPress?.(e) const exitEarlyIfFalse = outerOnPress?.(e)
if (exitEarlyIfFalse === false) return
const requiresWarning = Boolean( const requiresWarning = Boolean(
warnOnMismatchingTextChild && warnOnMismatchingTextChild &&
@@ -97,7 +108,7 @@ export function useLink({
e.preventDefault() e.preventDefault()
if (isExternal) { if (isExternal) {
Linking.openURL(href) openLink(href)
} else { } else {
/** /**
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
@@ -115,7 +126,7 @@ export function useLink({
href.startsWith('http') || href.startsWith('http') ||
href.startsWith('mailto') href.startsWith('mailto')
) { ) {
Linking.openURL(href) openLink(href)
} else { } else {
closeModal() // close any active modals closeModal() // close any active modals
@@ -136,15 +147,16 @@ export function useLink({
} }
}, },
[ [
href,
isExternal,
warnOnMismatchingTextChild,
navigation,
action,
displayText,
closeModal,
openModal,
outerOnPress, outerOnPress,
warnOnMismatchingTextChild,
displayText,
isExternal,
href,
openModal,
openLink,
closeModal,
action,
navigation,
], ],
) )
@@ -156,12 +168,7 @@ export function useLink({
} }
export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> & export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'> & { Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
/**
* Label for a11y. Defaults to the href.
*/
label?: string
}
/** /**
* A interactive element that renders as a `<a>` tag on the web. On mobile it * A interactive element that renders as a `<a>` tag on the web. On mobile it
@@ -176,6 +183,7 @@ export function Link({
to, to,
action = 'push', action = 'push',
onPress: outerOnPress, onPress: outerOnPress,
download,
...rest ...rest
}: LinkProps) { }: LinkProps) {
const {href, isExternal, onPress} = useLink({ const {href, isExternal, onPress} = useLink({
@@ -193,14 +201,15 @@ export function Link({
role="link" role="link"
accessibilityRole="link" accessibilityRole="link"
href={href} href={href}
onPress={onPress} onPress={download ? undefined : onPress}
{...web({ {...web({
hrefAttrs: { hrefAttrs: {
target: isExternal ? 'blank' : undefined, target: download ? undefined : isExternal ? 'blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined, rel: isExternal ? 'noopener noreferrer' : undefined,
download,
}, },
dataSet: { dataSet: {
// default to no underline, apply this ourselves // no underline, only `InlineLink` has underlines
noUnderline: '1', noUnderline: '1',
}, },
})}> })}>
@@ -210,13 +219,7 @@ export function Link({
} }
export type InlineLinkProps = React.PropsWithChildren< export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps & BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
TextStyleProp & {
/**
* Label for a11y. Defaults to the href.
*/
label?: string
}
> >
export function InlineLink({ export function InlineLink({
@@ -226,6 +229,8 @@ export function InlineLink({
warnOnMismatchingTextChild, warnOnMismatchingTextChild,
style, style,
onPress: outerOnPress, onPress: outerOnPress,
download,
selectable,
...rest ...rest
}: InlineLinkProps) { }: InlineLinkProps) {
const t = useTheme() const t = useTheme()
@@ -237,44 +242,55 @@ export function InlineLink({
warnOnMismatchingTextChild, warnOnMismatchingTextChild,
onPress: outerOnPress, onPress: outerOnPress,
}) })
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const { const {
state: pressed, state: pressed,
onIn: onPressIn, onIn: onPressIn,
onOut: onPressOut, onOut: onPressOut,
} = useInteractionState() } = useInteractionState()
const flattenedStyle = flatten(style)
return ( return (
<TouchableWithoutFeedback <Text
accessibilityRole="button" selectable={selectable}
onPress={onPress} label={href}
{...rest}
style={[
{color: t.palette.primary_500},
(hovered || focused || pressed) && {
...web({outline: 0}),
textDecorationLine: 'underline',
textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
},
flattenedStyle,
]}
role="link"
onPress={download ? undefined : onPress}
onPressIn={onPressIn} onPressIn={onPressIn}
onPressOut={onPressOut} onPressOut={onPressOut}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur}> onBlur={onBlur}
<Text onMouseEnter={onHoverIn}
label={href} onMouseLeave={onHoverOut}
{...rest} accessibilityRole="link"
style={[ href={href}
{color: t.palette.primary_500}, {...web({
(focused || pressed) && { hrefAttrs: {
outline: 0, target: download ? undefined : isExternal ? 'blank' : undefined,
textDecorationLine: 'underline', rel: isExternal ? 'noopener noreferrer' : undefined,
textDecorationColor: t.palette.primary_500, download,
}, },
flatten(style), dataSet: {
]} // default to no underline, apply this ourselves
role="link" noUnderline: '1',
accessibilityRole="link" },
href={href} })}>
{...web({ {children}
hrefAttrs: { </Text>
target: isExternal ? 'blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined,
},
})}>
{children}
</Text>
</TouchableWithoutFeedback>
) )
} }
+7 -2
View File
@@ -7,7 +7,7 @@ import Animated, {
withTiming, withTiming,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme, flatten} from '#/alf'
import {Props, useCommonSVGProps} from '#/components/icons/common' import {Props, useCommonSVGProps} from '#/components/icons/common'
import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader' import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
@@ -38,7 +38,12 @@ export function Loader(props: Props) {
]}> ]}>
<Icon <Icon
{...props} {...props}
style={[a.absolute, a.inset_0, props.style, t.atoms.text_contrast_high]} style={[
a.absolute,
a.inset_0,
t.atoms.text_contrast_high,
flatten(props.style),
]}
/> />
</Animated.View> </Animated.View>
) )
+1 -1
View File
@@ -41,7 +41,7 @@ export function Outer({
<Dialog.Inner <Dialog.Inner
accessibilityLabelledBy={titleId} accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId} accessibilityDescribedBy={descriptionId}
style={{width: 'auto', maxWidth: 400}}> style={[{width: 'auto', maxWidth: 400}]}>
{children} {children}
</Dialog.Inner> </Dialog.Inner>
</Context.Provider> </Context.Provider>
+17 -10
View File
@@ -1,9 +1,9 @@
import React from 'react' import React from 'react'
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api' import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
import {atoms as a, TextStyleProp} from '#/alf' import {atoms as a, TextStyleProp, flatten} from '#/alf'
import {InlineLink} from '#/components/Link' import {InlineLink} from '#/components/Link'
import {Text} from '#/components/Typography' import {Text, TextProps} from '#/components/Typography'
import {toShortUrl} from 'lib/strings/url-helpers' import {toShortUrl} from 'lib/strings/url-helpers'
import {getAgent} from '#/state/session' import {getAgent} from '#/state/session'
@@ -16,18 +16,20 @@ export function RichText({
numberOfLines, numberOfLines,
disableLinks, disableLinks,
resolveFacets = false, resolveFacets = false,
}: TextStyleProp & { selectable,
value: RichTextAPI | string }: TextStyleProp &
testID?: string Pick<TextProps, 'selectable'> & {
numberOfLines?: number value: RichTextAPI | string
disableLinks?: boolean testID?: string
resolveFacets?: boolean numberOfLines?: number
}) { disableLinks?: boolean
resolveFacets?: boolean
}) {
const detected = React.useRef(false) const detected = React.useRef(false)
const [richText, setRichText] = React.useState<RichTextAPI>(() => const [richText, setRichText] = React.useState<RichTextAPI>(() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
) )
const styles = [a.leading_normal, style] const styles = [a.leading_snug, flatten(style)]
React.useEffect(() => { React.useEffect(() => {
if (!resolveFacets) return if (!resolveFacets) return
@@ -50,6 +52,7 @@ export function RichText({
if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) { if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
return ( return (
<Text <Text
selectable={selectable}
testID={testID} testID={testID}
style={[ style={[
{ {
@@ -65,6 +68,7 @@ export function RichText({
} }
return ( return (
<Text <Text
selectable={selectable}
testID={testID} testID={testID}
style={styles} style={styles}
numberOfLines={numberOfLines} numberOfLines={numberOfLines}
@@ -88,6 +92,7 @@ export function RichText({
) { ) {
els.push( els.push(
<InlineLink <InlineLink
selectable={selectable}
key={key} key={key}
to={`/profile/${mention.did}`} to={`/profile/${mention.did}`}
style={[...styles, {pointerEvents: 'auto'}]} style={[...styles, {pointerEvents: 'auto'}]}
@@ -102,6 +107,7 @@ export function RichText({
} else { } else {
els.push( els.push(
<InlineLink <InlineLink
selectable={selectable}
key={key} key={key}
to={link.uri} to={link.uri}
style={[...styles, {pointerEvents: 'auto'}]} style={[...styles, {pointerEvents: 'auto'}]}
@@ -120,6 +126,7 @@ export function RichText({
return ( return (
<Text <Text
selectable={selectable}
testID={testID} testID={testID}
style={styles} style={styles}
numberOfLines={numberOfLines} numberOfLines={numberOfLines}
+19 -19
View File
@@ -1,7 +1,16 @@
import React from 'react' import React from 'react'
import {Text as RNText, TextStyle, TextProps} from 'react-native' import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
import {UITextView} from 'react-native-ui-text-view'
import {useTheme, atoms, web, flatten} from '#/alf' import {useTheme, atoms, web, flatten} from '#/alf'
import {isIOS} from '#/platform/detection'
export type TextProps = RNTextProps & {
/**
* Lets the user select text, to use the native copy and paste functionality.
*/
selectable?: boolean
}
/** /**
* Util to calculate lineHeight from a text size atom and a leading atom * Util to calculate lineHeight from a text size atom and a leading atom
@@ -44,27 +53,24 @@ function normalizeTextStyles(styles: TextStyle[]) {
/** /**
* Our main text component. Use this most of the time. * Our main text component. Use this most of the time.
*/ */
export function Text({style, ...rest}: TextProps) { export function Text({style, selectable, ...rest}: TextProps) {
const t = useTheme() const t = useTheme()
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)]) const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
return <RNText style={s} {...rest} /> return selectable && isIOS ? (
<UITextView style={s} {...rest} />
) : (
<RNText selectable={selectable} style={s} {...rest} />
)
} }
export function createHeadingElement({level}: {level: number}) { export function createHeadingElement({level}: {level: number}) {
return function HeadingElement({style, ...rest}: TextProps) { return function HeadingElement({style, ...rest}: TextProps) {
const t = useTheme()
const attr = const attr =
web({ web({
role: 'heading', role: 'heading',
'aria-level': level, 'aria-level': level,
}) || {} }) || {}
return ( return <Text {...attr} {...rest} style={style} />
<RNText
{...attr}
{...rest}
style={normalizeTextStyles([t.atoms.text, flatten(style)])}
/>
)
} }
} }
@@ -78,21 +84,15 @@ export const H4 = createHeadingElement({level: 4})
export const H5 = createHeadingElement({level: 5}) export const H5 = createHeadingElement({level: 5})
export const H6 = createHeadingElement({level: 6}) export const H6 = createHeadingElement({level: 6})
export function P({style, ...rest}: TextProps) { export function P({style, ...rest}: TextProps) {
const t = useTheme()
const attr = const attr =
web({ web({
role: 'paragraph', role: 'paragraph',
}) || {} }) || {}
return ( return (
<RNText <Text
{...attr} {...attr}
{...rest} {...rest}
style={normalizeTextStyles([ style={[atoms.text_md, atoms.leading_normal, flatten(style)]}
atoms.text_md,
atoms.leading_normal,
t.atoms.text,
flatten(style),
])}
/> />
) )
} }
@@ -98,7 +98,7 @@ export function DateField({
timeZoneName={'Etc/UTC'} timeZoneName={'Etc/UTC'}
display="spinner" display="spinner"
// @ts-ignore applies in iOS only -prf // @ts-ignore applies in iOS only -prf
themeVariant={t.name === 'dark' ? 'dark' : 'light'} themeVariant={t.name === 'light' ? 'light' : 'dark'}
value={new Date(value)} value={new Date(value)}
onChange={onChangeInternal} onChange={onChangeInternal}
/> />
+1 -1
View File
@@ -47,7 +47,7 @@ export function DateField({
mode="date" mode="date"
timeZoneName={'Etc/UTC'} timeZoneName={'Etc/UTC'}
display="spinner" display="spinner"
themeVariant={t.name === 'dark' ? 'dark' : 'light'} themeVariant={t.name === 'light' ? 'light' : 'dark'}
value={new Date(value)} value={new Date(value)}
onChange={onChangeInternal} onChange={onChangeInternal}
/> />
+18 -42
View File
@@ -5,15 +5,13 @@ import {
TextInputProps, TextInputProps,
TextStyle, TextStyle,
ViewStyle, ViewStyle,
Pressable,
StyleSheet, StyleSheet,
AccessibilityProps, AccessibilityProps,
} from 'react-native' } from 'react-native'
import {HITSLOP_20} from 'lib/constants' import {HITSLOP_20} from 'lib/constants'
import {isWeb} from '#/platform/detection' import {useTheme, atoms as a, web, tokens, android} from '#/alf'
import {useTheme, atoms as a, web, tokens, android, flatten} from '#/alf' import {Text} from '#/components/Typography'
import {Text, leading} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common' import {Props as SVGIconProps} from '#/components/icons/common'
@@ -41,7 +39,6 @@ export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
export function Root({children, isInvalid = false}: RootProps) { export function Root({children, isInvalid = false}: RootProps) {
const inputRef = React.useRef<TextInput>(null) const inputRef = React.useRef<TextInput>(null)
const rootRef = React.useRef<View>(null)
const { const {
state: hovered, state: hovered,
onIn: onHoverIn, onIn: onHoverIn,
@@ -72,35 +69,17 @@ export function Root({children, isInvalid = false}: RootProps) {
], ],
) )
React.useLayoutEffect(() => {
const root = rootRef.current
if (!root || !isWeb) return
// @ts-ignore web only
root.tabIndex = -1
}, [])
return ( return (
<Context.Provider value={context}> <Context.Provider value={context}>
<Pressable <View
accessibilityRole="button" style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
ref={rootRef} {...web({
role="none" onClick: () => inputRef.current?.focus(),
style={[ onMouseOver: onHoverIn,
a.flex_row, onMouseOut: onHoverOut,
a.align_center, })}>
a.relative,
a.w_full,
a.px_md,
{
paddingVertical: 14,
},
]}
// onPressIn/out don't work on android web
onPress={() => inputRef.current?.focus()}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}>
{children} {children}
</Pressable> </View>
</Context.Provider> </Context.Provider>
) )
} }
@@ -149,7 +128,6 @@ export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
value: string value: string
onChangeText: (value: string) => void onChangeText: (value: string) => void
isInvalid?: boolean isInvalid?: boolean
disabled?: boolean
} }
export function createInput(Component: typeof TextInput) { export function createInput(Component: typeof TextInput) {
@@ -188,7 +166,6 @@ export function createInput(Component: typeof TextInput) {
<Component <Component
accessibilityHint={undefined} accessibilityHint={undefined}
{...rest} {...rest}
aria-label={label}
accessibilityLabel={label} accessibilityLabel={label}
ref={ctx.inputRef} ref={ctx.inputRef}
value={value} value={value}
@@ -205,17 +182,17 @@ export function createInput(Component: typeof TextInput) {
a.text_md, a.text_md,
t.atoms.text, t.atoms.text,
a.px_xs, a.px_xs,
android({
paddingBottom: 2,
}),
{ {
lineHeight: rest.multiline // paddingVertical doesn't work w/multiline - esb
? leading(a.text_md, a.leading_normal) paddingTop: 14,
: a.text_md.fontSize * 1.1875, paddingBottom: 14,
lineHeight: a.text_md.fontSize * 1.1875,
textAlignVertical: rest.multiline ? 'top' : undefined, textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 60 : undefined, minHeight: rest.multiline ? 80 : undefined,
}, },
flatten(rest.style), android({
paddingBottom: 16,
}),
]} ]}
/> />
@@ -317,7 +294,6 @@ export function Suffix({
const ctx = React.useContext(Context) const ctx = React.useContext(Context)
return ( return (
<Text <Text
aria-label={label}
accessibilityLabel={label} accessibilityLabel={label}
accessibilityHint={accessibilityHint} accessibilityHint={accessibilityHint}
style={[ style={[
-60
View File
@@ -1,60 +0,0 @@
/**
* APP-700
*
* This is a temporary debug setting we're running on the Web build to
* help the protocol team test some changes.
*
* It should be removed in ~2 weeks. It should only be used on the Web
* version of the app.
*/
import {useState, useCallback, useEffect} from 'react'
import {BskyAgent} from '@atproto/api'
import * as Storage from 'lib/storage'
export function useDebugHeaderSetting(agent: BskyAgent): [boolean, () => void] {
const [enabled, setEnabled] = useState<boolean>(false)
useEffect(() => {
async function check() {
if (await isEnabled()) {
setEnabled(true)
}
}
check()
}, [])
const toggle = useCallback(() => {
if (!enabled) {
Storage.saveString('set-header-x-appview-proxy', 'yes')
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
setEnabled(true)
} else {
Storage.remove('set-header-x-appview-proxy')
agent.api.xrpc.unsetHeader('x-appview-proxy')
setEnabled(false)
}
}, [setEnabled, enabled, agent])
return [enabled, toggle]
}
export function setDebugHeader(agent: BskyAgent, enabled: boolean) {
if (enabled) {
Storage.saveString('set-header-x-appview-proxy', 'yes')
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
} else {
Storage.remove('set-header-x-appview-proxy')
agent.api.xrpc.unsetHeader('x-appview-proxy')
}
}
export async function applyDebugHeader(agent: BskyAgent) {
if (await isEnabled()) {
agent.api.xrpc.setHeader('x-appview-proxy', 'true')
}
}
async function isEnabled() {
return (await Storage.loadString('set-header-x-appview-proxy')) === 'yes'
}
+7 -90
View File
@@ -3,9 +3,8 @@ import {Insets, Platform} from 'react-native'
export const LOCAL_DEV_SERVICE = export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev' export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const PROD_SERVICE = 'https://bsky.social' export const BSKY_SERVICE = 'https://bsky.social'
export const DEFAULT_SERVICE = PROD_SERVICE export const DEFAULT_SERVICE = BSKY_SERVICE
const HELP_DESK_LANG = 'en-us' const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}` export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
@@ -36,92 +35,12 @@ export const MAX_GRAPHEME_LENGTH = 300
// but increasing limit per user feedback // but increasing limit per user feedback
export const MAX_ALT_TEXT = 1000 export const MAX_ALT_TEXT = 1000
export function IS_LOCAL_DEV(url: string) { export function IS_PROD_SERVICE(url?: string) {
return url.includes('localhost') return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
} }
export function IS_STAGING(url: string) {
return url.startsWith('https://staging.bsky.dev')
}
export function IS_PROD(url: string) {
// NOTE
// until open federation, "production" is defined as the main server
// this definition will not work once federation is enabled!
// -prf
return (
url.startsWith('https://bsky.social') ||
url.startsWith('https://api.bsky.app') ||
/bsky\.network\/?$/.test(url)
)
}
export const PROD_TEAM_HANDLES = [
'jay.bsky.social',
'pfrazee.com',
'divy.zone',
'dholms.xyz',
'why.bsky.world',
'iamrosewang.bsky.social',
]
export const STAGING_TEAM_HANDLES = [
'arcalinea.staging.bsky.dev',
'paul.staging.bsky.dev',
'paul2.staging.bsky.dev',
]
export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test']
export function TEAM_HANDLES(serviceUrl: string) {
if (serviceUrl.includes('localhost')) {
return DEV_TEAM_HANDLES
} else if (serviceUrl.includes('staging')) {
return STAGING_TEAM_HANDLES
} else {
return PROD_TEAM_HANDLES
}
}
export const STAGING_DEFAULT_FEED = (rkey: string) =>
`at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}`
export const PROD_DEFAULT_FEED = (rkey: string) => export const PROD_DEFAULT_FEED = (rkey: string) =>
`at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}` `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
export async function DEFAULT_FEEDS(
serviceUrl: string,
resolveHandle: (name: string) => Promise<string>,
) {
// TODO: remove this when the test suite no longer relies on it
if (IS_LOCAL_DEV(serviceUrl)) {
// local dev
const aliceDid = await resolveHandle('alice.test')
return {
pinned: [
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
],
saved: [
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
],
}
} else if (IS_STAGING(serviceUrl)) {
// staging
return {
pinned: [STAGING_DEFAULT_FEED('whats-hot')],
saved: [
STAGING_DEFAULT_FEED('bsky-team'),
STAGING_DEFAULT_FEED('with-friends'),
STAGING_DEFAULT_FEED('whats-hot'),
STAGING_DEFAULT_FEED('hot-classic'),
],
}
} else {
// production
return {
pinned: [PROD_DEFAULT_FEED('whats-hot')],
saved: [PROD_DEFAULT_FEED('whats-hot')],
}
}
}
export const POST_IMG_MAX = { export const POST_IMG_MAX = {
width: 2000, width: 2000,
@@ -135,13 +54,11 @@ export const STAGING_LINK_META_PROXY =
export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url=' export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
export function LINK_META_PROXY(serviceUrl: string) { export function LINK_META_PROXY(serviceUrl: string) {
if (IS_LOCAL_DEV(serviceUrl)) { if (IS_PROD_SERVICE(serviceUrl)) {
return STAGING_LINK_META_PROXY
} else if (IS_STAGING(serviceUrl)) {
return STAGING_LINK_META_PROXY
} else {
return PROD_LINK_META_PROXY return PROD_LINK_META_PROXY
} }
return STAGING_LINK_META_PROXY
} }
export const STATUS_PAGE_URL = 'https://status.bsky.app/' export const STATUS_PAGE_URL = 'https://status.bsky.app/'
-256
View File
@@ -1,256 +0,0 @@
import {CountryCode} from 'libphonenumber-js'
// ISO 3166-1 alpha-2 codes
export interface CountryCodeMap {
code2: CountryCode
name: string
}
export const COUNTRY_CODES: CountryCodeMap[] = [
{code2: 'AF', name: 'Afghanistan (+93)'},
{code2: 'AX', name: 'Åland Islands (+358)'},
{code2: 'AL', name: 'Albania (+355)'},
{code2: 'DZ', name: 'Algeria (+213)'},
{code2: 'AS', name: 'American Samoa (+1)'},
{code2: 'AD', name: 'Andorra (+376)'},
{code2: 'AO', name: 'Angola (+244)'},
{code2: 'AI', name: 'Anguilla (+1)'},
{code2: 'AG', name: 'Antigua and Barbuda (+1)'},
{code2: 'AR', name: 'Argentina (+54)'},
{code2: 'AM', name: 'Armenia (+374)'},
{code2: 'AW', name: 'Aruba (+297)'},
{code2: 'AU', name: 'Australia (+61)'},
{code2: 'AT', name: 'Austria (+43)'},
{code2: 'AZ', name: 'Azerbaijan (+994)'},
{code2: 'BS', name: 'Bahamas (+1)'},
{code2: 'BH', name: 'Bahrain (+973)'},
{code2: 'BD', name: 'Bangladesh (+880)'},
{code2: 'BB', name: 'Barbados (+1)'},
{code2: 'BY', name: 'Belarus (+375)'},
{code2: 'BE', name: 'Belgium (+32)'},
{code2: 'BZ', name: 'Belize (+501)'},
{code2: 'BJ', name: 'Benin (+229)'},
{code2: 'BM', name: 'Bermuda (+1)'},
{code2: 'BT', name: 'Bhutan (+975)'},
{code2: 'BO', name: 'Bolivia (Plurinational State of) (+591)'},
{code2: 'BQ', name: 'Bonaire, Sint Eustatius and Saba (+599)'},
{code2: 'BA', name: 'Bosnia and Herzegovina (+387)'},
{code2: 'BW', name: 'Botswana (+267)'},
{code2: 'BR', name: 'Brazil (+55)'},
{code2: 'IO', name: 'British Indian Ocean Territory (+246)'},
{code2: 'BN', name: 'Brunei Darussalam (+673)'},
{code2: 'BG', name: 'Bulgaria (+359)'},
{code2: 'BF', name: 'Burkina Faso (+226)'},
{code2: 'BI', name: 'Burundi (+257)'},
{code2: 'CV', name: 'Cabo Verde (+238)'},
{code2: 'KH', name: 'Cambodia (+855)'},
{code2: 'CM', name: 'Cameroon (+237)'},
{code2: 'CA', name: 'Canada (+1)'},
{code2: 'KY', name: 'Cayman Islands (+1)'},
{code2: 'CF', name: 'Central African Republic (+236)'},
{code2: 'TD', name: 'Chad (+235)'},
{code2: 'CL', name: 'Chile (+56)'},
{code2: 'CN', name: 'China (+86)'},
{code2: 'CX', name: 'Christmas Island (+61)'},
{code2: 'CC', name: 'Cocos (Keeling) Islands (+61)'},
{code2: 'CO', name: 'Colombia (+57)'},
{code2: 'KM', name: 'Comoros (+269)'},
{code2: 'CG', name: 'Congo (+242)'},
{code2: 'CD', name: 'Congo, Democratic Republic of the (+243)'},
{code2: 'CK', name: 'Cook Islands (+682)'},
{code2: 'CR', name: 'Costa Rica (+506)'},
{code2: 'CI', name: "Côte d'Ivoire (+225)"},
{code2: 'HR', name: 'Croatia (+385)'},
{code2: 'CU', name: 'Cuba (+53)'},
{code2: 'CW', name: 'Curaçao (+599)'},
{code2: 'CY', name: 'Cyprus (+357)'},
{code2: 'CZ', name: 'Czechia (+420)'},
{code2: 'DK', name: 'Denmark (+45)'},
{code2: 'DJ', name: 'Djibouti (+253)'},
{code2: 'DM', name: 'Dominica (+1)'},
{code2: 'DO', name: 'Dominican Republic (+1)'},
{code2: 'EC', name: 'Ecuador (+593)'},
{code2: 'EG', name: 'Egypt (+20)'},
{code2: 'SV', name: 'El Salvador (+503)'},
{code2: 'GQ', name: 'Equatorial Guinea (+240)'},
{code2: 'ER', name: 'Eritrea (+291)'},
{code2: 'EE', name: 'Estonia (+372)'},
{code2: 'SZ', name: 'Eswatini (+268)'},
{code2: 'ET', name: 'Ethiopia (+251)'},
{code2: 'FK', name: 'Falkland Islands (Malvinas) (+500)'},
{code2: 'FO', name: 'Faroe Islands (+298)'},
{code2: 'FJ', name: 'Fiji (+679)'},
{code2: 'FI', name: 'Finland (+358)'},
{code2: 'FR', name: 'France (+33)'},
{code2: 'GF', name: 'French Guiana (+594)'},
{code2: 'PF', name: 'French Polynesia (+689)'},
{code2: 'GA', name: 'Gabon (+241)'},
{code2: 'GM', name: 'Gambia (+220)'},
{code2: 'GE', name: 'Georgia (+995)'},
{code2: 'DE', name: 'Germany (+49)'},
{code2: 'GH', name: 'Ghana (+233)'},
{code2: 'GI', name: 'Gibraltar (+350)'},
{code2: 'GR', name: 'Greece (+30)'},
{code2: 'GL', name: 'Greenland (+299)'},
{code2: 'GD', name: 'Grenada (+1)'},
{code2: 'GP', name: 'Guadeloupe (+590)'},
{code2: 'GU', name: 'Guam (+1)'},
{code2: 'GT', name: 'Guatemala (+502)'},
{code2: 'GG', name: 'Guernsey (+44)'},
{code2: 'GN', name: 'Guinea (+224)'},
{code2: 'GW', name: 'Guinea-Bissau (+245)'},
{code2: 'GY', name: 'Guyana (+592)'},
{code2: 'HT', name: 'Haiti (+509)'},
{code2: 'VA', name: 'Holy See (+39)'},
{code2: 'HN', name: 'Honduras (+504)'},
{code2: 'HK', name: 'Hong Kong (+852)'},
{code2: 'HU', name: 'Hungary (+36)'},
{code2: 'IS', name: 'Iceland (+354)'},
{code2: 'IN', name: 'India (+91)'},
{code2: 'ID', name: 'Indonesia (+62)'},
{code2: 'IR', name: 'Iran (Islamic Republic of) (+98)'},
{code2: 'IQ', name: 'Iraq (+964)'},
{code2: 'IE', name: 'Ireland (+353)'},
{code2: 'IM', name: 'Isle of Man (+44)'},
{code2: 'IL', name: 'Israel (+972)'},
{code2: 'IT', name: 'Italy (+39)'},
{code2: 'JM', name: 'Jamaica (+1)'},
{code2: 'JP', name: 'Japan (+81)'},
{code2: 'JE', name: 'Jersey (+44)'},
{code2: 'JO', name: 'Jordan (+962)'},
{code2: 'KZ', name: 'Kazakhstan (+7)'},
{code2: 'KE', name: 'Kenya (+254)'},
{code2: 'KI', name: 'Kiribati (+686)'},
{code2: 'KP', name: "Korea (Democratic People's Republic of) (+850)"},
{code2: 'KR', name: 'Korea, Republic of (+82)'},
{code2: 'KW', name: 'Kuwait (+965)'},
{code2: 'KG', name: 'Kyrgyzstan (+996)'},
{code2: 'LA', name: "Lao People's Democratic Republic (+856)"},
{code2: 'LV', name: 'Latvia (+371)'},
{code2: 'LB', name: 'Lebanon (+961)'},
{code2: 'LS', name: 'Lesotho (+266)'},
{code2: 'LR', name: 'Liberia (+231)'},
{code2: 'LY', name: 'Libya (+218)'},
{code2: 'LI', name: 'Liechtenstein (+423)'},
{code2: 'LT', name: 'Lithuania (+370)'},
{code2: 'LU', name: 'Luxembourg (+352)'},
{code2: 'MO', name: 'Macao (+853)'},
{code2: 'MG', name: 'Madagascar (+261)'},
{code2: 'MW', name: 'Malawi (+265)'},
{code2: 'MY', name: 'Malaysia (+60)'},
{code2: 'MV', name: 'Maldives (+960)'},
{code2: 'ML', name: 'Mali (+223)'},
{code2: 'MT', name: 'Malta (+356)'},
{code2: 'MH', name: 'Marshall Islands (+692)'},
{code2: 'MQ', name: 'Martinique (+596)'},
{code2: 'MR', name: 'Mauritania (+222)'},
{code2: 'MU', name: 'Mauritius (+230)'},
{code2: 'YT', name: 'Mayotte (+262)'},
{code2: 'MX', name: 'Mexico (+52)'},
{code2: 'FM', name: 'Micronesia (Federated States of) (+691)'},
{code2: 'MD', name: 'Moldova, Republic of (+373)'},
{code2: 'MC', name: 'Monaco (+377)'},
{code2: 'MN', name: 'Mongolia (+976)'},
{code2: 'ME', name: 'Montenegro (+382)'},
{code2: 'MS', name: 'Montserrat (+1)'},
{code2: 'MA', name: 'Morocco (+212)'},
{code2: 'MZ', name: 'Mozambique (+258)'},
{code2: 'MM', name: 'Myanmar (+95)'},
{code2: 'NA', name: 'Namibia (+264)'},
{code2: 'NR', name: 'Nauru (+674)'},
{code2: 'NP', name: 'Nepal (+977)'},
{code2: 'NL', name: 'Netherlands, Kingdom of the (+31)'},
{code2: 'NC', name: 'New Caledonia (+687)'},
{code2: 'NZ', name: 'New Zealand (+64)'},
{code2: 'NI', name: 'Nicaragua (+505)'},
{code2: 'NE', name: 'Niger (+227)'},
{code2: 'NG', name: 'Nigeria (+234)'},
{code2: 'NU', name: 'Niue (+683)'},
{code2: 'NF', name: 'Norfolk Island (+672)'},
{code2: 'MK', name: 'North Macedonia (+389)'},
{code2: 'MP', name: 'Northern Mariana Islands (+1)'},
{code2: 'NO', name: 'Norway (+47)'},
{code2: 'OM', name: 'Oman (+968)'},
{code2: 'PK', name: 'Pakistan (+92)'},
{code2: 'PW', name: 'Palau (+680)'},
{code2: 'PS', name: 'Palestine, State of (+970)'},
{code2: 'PA', name: 'Panama (+507)'},
{code2: 'PG', name: 'Papua New Guinea (+675)'},
{code2: 'PY', name: 'Paraguay (+595)'},
{code2: 'PE', name: 'Peru (+51)'},
{code2: 'PH', name: 'Philippines (+63)'},
{code2: 'PL', name: 'Poland (+48)'},
{code2: 'PT', name: 'Portugal (+351)'},
{code2: 'PR', name: 'Puerto Rico (+1)'},
{code2: 'QA', name: 'Qatar (+974)'},
{code2: 'RE', name: 'Réunion (+262)'},
{code2: 'RO', name: 'Romania (+40)'},
{code2: 'RU', name: 'Russian Federation (+7)'},
{code2: 'RW', name: 'Rwanda (+250)'},
{code2: 'BL', name: 'Saint Barthélemy (+590)'},
{code2: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha (+290)'},
{code2: 'KN', name: 'Saint Kitts and Nevis (+1)'},
{code2: 'LC', name: 'Saint Lucia (+1)'},
{code2: 'MF', name: 'Saint Martin (French part) (+590)'},
{code2: 'PM', name: 'Saint Pierre and Miquelon (+508)'},
{code2: 'VC', name: 'Saint Vincent and the Grenadines (+1)'},
{code2: 'WS', name: 'Samoa (+685)'},
{code2: 'SM', name: 'San Marino (+378)'},
{code2: 'ST', name: 'Sao Tome and Principe (+239)'},
{code2: 'SA', name: 'Saudi Arabia (+966)'},
{code2: 'SN', name: 'Senegal (+221)'},
{code2: 'RS', name: 'Serbia (+381)'},
{code2: 'SC', name: 'Seychelles (+248)'},
{code2: 'SL', name: 'Sierra Leone (+232)'},
{code2: 'SG', name: 'Singapore (+65)'},
{code2: 'SX', name: 'Sint Maarten (Dutch part) (+1)'},
{code2: 'SK', name: 'Slovakia (+421)'},
{code2: 'SI', name: 'Slovenia (+386)'},
{code2: 'SB', name: 'Solomon Islands (+677)'},
{code2: 'SO', name: 'Somalia (+252)'},
{code2: 'ZA', name: 'South Africa (+27)'},
{code2: 'SS', name: 'South Sudan (+211)'},
{code2: 'ES', name: 'Spain (+34)'},
{code2: 'LK', name: 'Sri Lanka (+94)'},
{code2: 'SD', name: 'Sudan (+249)'},
{code2: 'SR', name: 'Suriname (+597)'},
{code2: 'SJ', name: 'Svalbard and Jan Mayen (+47)'},
{code2: 'SE', name: 'Sweden (+46)'},
{code2: 'CH', name: 'Switzerland (+41)'},
{code2: 'SY', name: 'Syrian Arab Republic (+963)'},
{code2: 'TW', name: 'Taiwan (+886)'},
{code2: 'TJ', name: 'Tajikistan (+992)'},
{code2: 'TZ', name: 'Tanzania, United Republic of (+255)'},
{code2: 'TH', name: 'Thailand (+66)'},
{code2: 'TL', name: 'Timor-Leste (+670)'},
{code2: 'TG', name: 'Togo (+228)'},
{code2: 'TK', name: 'Tokelau (+690)'},
{code2: 'TO', name: 'Tonga (+676)'},
{code2: 'TT', name: 'Trinidad and Tobago (+1)'},
{code2: 'TN', name: 'Tunisia (+216)'},
{code2: 'TR', name: 'Türkiye (+90)'},
{code2: 'TM', name: 'Turkmenistan (+993)'},
{code2: 'TC', name: 'Turks and Caicos Islands (+1)'},
{code2: 'TV', name: 'Tuvalu (+688)'},
{code2: 'UG', name: 'Uganda (+256)'},
{code2: 'UA', name: 'Ukraine (+380)'},
{code2: 'AE', name: 'United Arab Emirates (+971)'},
{
code2: 'GB',
name: 'United Kingdom of Great Britain and Northern Ireland (+44)',
},
{code2: 'US', name: 'United States of America (+1)'},
{code2: 'UY', name: 'Uruguay (+598)'},
{code2: 'UZ', name: 'Uzbekistan (+998)'},
{code2: 'VU', name: 'Vanuatu (+678)'},
{code2: 'VE', name: 'Venezuela (Bolivarian Republic of) (+58)'},
{code2: 'VN', name: 'Viet Nam (+84)'},
{code2: 'VG', name: 'Virgin Islands (British) (+1)'},
{code2: 'VI', name: 'Virgin Islands (U.S.) (+1)'},
{code2: 'WF', name: 'Wallis and Futuna (+681)'},
{code2: 'EH', name: 'Western Sahara (+212)'},
{code2: 'YE', name: 'Yemen (+967)'},
{code2: 'ZM', name: 'Zambia (+260)'},
{code2: 'ZW', name: 'Zimbabwe (+263)'},
]
+1 -1
View File
@@ -26,7 +26,7 @@ export interface LinkMeta {
export async function getLinkMeta( export async function getLinkMeta(
agent: BskyAgent, agent: BskyAgent,
url: string, url: string,
timeout = 5e3, timeout = 15e3,
): Promise<LinkMeta> { ): Promise<LinkMeta> {
if (isBskyAppUrl(url)) { if (isBskyAppUrl(url)) {
return extractBskyMeta(agent, url) return extractBskyMeta(agent, url)
+2 -1
View File
@@ -5,12 +5,13 @@ import {compressIfNeeded} from './manip'
let _imageCounter = 0 let _imageCounter = 0
async function getFile() { async function getFile() {
const files = await RNFS.readDir( let files = await RNFS.readDir(
RNFS.LibraryDirectoryPath.split('/') RNFS.LibraryDirectoryPath.split('/')
.slice(0, -5) .slice(0, -5)
.concat(['Media', 'DCIM', '100APPLE']) .concat(['Media', 'DCIM', '100APPLE'])
.join('/'), .join('/'),
) )
files = files.filter(file => file.path.endsWith('.JPG'))
const file = files[_imageCounter++ % files.length] const file = files[_imageCounter++ % files.length]
return await compressIfNeeded({ return await compressIfNeeded({
path: file.path, path: file.path,
+1 -1
View File
@@ -31,7 +31,7 @@ export type CommonNavigatorParams = {
CopyrightPolicy: undefined CopyrightPolicy: undefined
AppPasswords: undefined AppPasswords: undefined
SavedFeeds: undefined SavedFeeds: undefined
PreferencesHomeFeed: undefined PreferencesFollowingFeed: undefined
PreferencesThreads: undefined PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined PreferencesExternalEmbeds: undefined
} }
+14 -14
View File
@@ -343,45 +343,45 @@ export function parseEmbedPlayerFromUrl(
} }
} }
export function getPlayerHeight({ export function getPlayerAspect({
type, type,
width,
hasThumb, hasThumb,
width,
}: { }: {
type: EmbedPlayerParams['type'] type: EmbedPlayerParams['type']
width: number
hasThumb: boolean hasThumb: boolean
}) { width: number
if (!hasThumb) return (width / 16) * 9 }): {aspectRatio?: number; height?: number} {
if (!hasThumb) return {aspectRatio: 16 / 9}
switch (type) { switch (type) {
case 'youtube_video': case 'youtube_video':
case 'twitch_video': case 'twitch_video':
case 'vimeo_video': case 'vimeo_video':
return (width / 16) * 9 return {aspectRatio: 16 / 9}
case 'youtube_short': case 'youtube_short':
if (SCREEN_HEIGHT < 600) { if (SCREEN_HEIGHT < 600) {
return ((width / 9) * 16) / 1.75 return {aspectRatio: (9 / 16) * 1.75}
} else { } else {
return ((width / 9) * 16) / 1.5 return {aspectRatio: (9 / 16) * 1.5}
} }
case 'spotify_album': case 'spotify_album':
case 'apple_music_album': case 'apple_music_album':
case 'apple_music_playlist': case 'apple_music_playlist':
case 'spotify_playlist': case 'spotify_playlist':
case 'soundcloud_set': case 'soundcloud_set':
return 380 return {height: 380}
case 'spotify_song': case 'spotify_song':
if (width <= 300) { if (width <= 300) {
return 155 return {height: 155}
} }
return 232 return {height: 232}
case 'soundcloud_track': case 'soundcloud_track':
return 165 return {height: 165}
case 'apple_music_song': case 'apple_music_song':
return 150 return {height: 150}
default: default:
return width return {aspectRatio: 16 / 9}
} }
} }
+29
View File
@@ -1,3 +1,8 @@
// Regex from the go implementation
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
const VALIDATE_REGEX =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
export function makeValidHandle(str: string): string { export function makeValidHandle(str: string): string {
if (str.length > 20) { if (str.length > 20) {
str = str.slice(0, 20) str = str.slice(0, 20)
@@ -19,3 +24,27 @@ export function isInvalidHandle(handle: string): boolean {
export function sanitizeHandle(handle: string, prefix = ''): string { export function sanitizeHandle(handle: string, prefix = ''): string {
return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}` return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
} }
export interface IsValidHandle {
handleChars: boolean
frontLength: boolean
totalLength: boolean
overall: boolean
}
// More checks from https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/index.ts#L72
export function validateHandle(str: string, userDomain: string): IsValidHandle {
const fullHandle = createFullHandle(str, userDomain)
const results = {
handleChars:
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
frontLength: str.length >= 3,
totalLength: fullHandle.length <= 253,
}
return {
...results,
overall: !Object.values(results).includes(false),
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ export function ago(date: number | string | Date): string {
} else if (diffSeconds < DAY) { } else if (diffSeconds < DAY) {
return `${Math.floor(diffSeconds / HOUR)}h` return `${Math.floor(diffSeconds / HOUR)}h`
} else if (diffSeconds < MONTH) { } else if (diffSeconds < MONTH) {
return `${Math.floor(diffSeconds / DAY)}d` return `${Math.round(diffSeconds / DAY)}d`
} else if (diffSeconds < YEAR) { } else if (diffSeconds < YEAR) {
return `${Math.floor(diffSeconds / MONTH)}mo` return `${Math.floor(diffSeconds / MONTH)}mo`
} else { } else {
+2 -2
View File
@@ -1,5 +1,5 @@
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {PROD_SERVICE} from 'lib/constants' import {BSKY_SERVICE} from 'lib/constants'
import TLDs from 'tlds' import TLDs from 'tlds'
import psl from 'psl' import psl from 'psl'
@@ -28,7 +28,7 @@ export function makeRecordUri(
export function toNiceDomain(url: string): string { export function toNiceDomain(url: string): string {
try { try {
const urlp = new URL(url) const urlp = new URL(url)
if (`https://${urlp.host}` === PROD_SERVICE) { if (`https://${urlp.host}` === BSKY_SERVICE) {
return 'Bluesky Social' return 'Bluesky Social'
} }
return urlp.host ? urlp.host : url return urlp.host ? urlp.host : url
+20 -1
View File
@@ -306,7 +306,7 @@ export const darkTheme: Theme = {
// non-standard // non-standard
textVeryLight: darkPalette.contrast_400, textVeryLight: darkPalette.contrast_400,
replyLine: darkPalette.contrast_100, replyLine: darkPalette.contrast_200,
replyLineDot: darkPalette.contrast_200, replyLineDot: darkPalette.contrast_200,
unreadNotifBg: darkPalette.primary_975, unreadNotifBg: darkPalette.primary_975,
unreadNotifBorder: darkPalette.primary_900, unreadNotifBorder: darkPalette.primary_900,
@@ -344,6 +344,25 @@ export const dimTheme: Theme = {
default: { default: {
...darkTheme.palette.default, ...darkTheme.palette.default,
background: dimPalette.black, background: dimPalette.black,
backgroundLight: dimPalette.contrast_50,
text: dimPalette.white,
textLight: dimPalette.contrast_700,
textInverted: dimPalette.black,
link: dimPalette.primary_500,
border: dimPalette.contrast_100,
borderDark: dimPalette.contrast_200,
icon: dimPalette.contrast_500,
// non-standard
textVeryLight: dimPalette.contrast_400,
replyLine: dimPalette.contrast_200,
replyLineDot: dimPalette.contrast_200,
unreadNotifBg: `hsl(211, 48%, 17%)`,
unreadNotifBorder: `hsl(211, 48%, 30%)`,
postCtrl: dimPalette.contrast_500,
brandText: dimPalette.primary_500,
emptyStateIcon: dimPalette.contrast_300,
borderLinkHover: dimPalette.contrast_300,
}, },
}, },
} }
+2
View File
@@ -140,6 +140,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ca return AppLanguage.ca
case 'zh-CN': case 'zh-CN':
return AppLanguage.zh_CN return AppLanguage.zh_CN
case 'it':
return AppLanguage.it
default: default:
continue continue
} }
+6
View File
@@ -14,6 +14,7 @@ import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
import {messages as messagesUk} from '#/locale/locales/uk/messages' import {messages as messagesUk} from '#/locale/locales/uk/messages'
import {messages as messagesCa} from '#/locale/locales/ca/messages' import {messages as messagesCa} from '#/locale/locales/ca/messages'
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages' import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
import {messages as messagesIt} from '#/locale/locales/it/messages'
import {sanitizeAppLanguageSetting} from '#/locale/helpers' import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages' import {AppLanguage} from '#/locale/languages'
@@ -65,6 +66,11 @@ export async function dynamicActivate(locale: AppLanguage) {
} }
case AppLanguage.zh_CN: { case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN}) i18n.loadAndActivate({locale, messages: messagesZh_CN})
break
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
break
} }
default: { default: {
i18n.loadAndActivate({locale, messages: messagesEn}) i18n.loadAndActivate({locale, messages: messagesEn})
+4
View File
@@ -56,6 +56,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/zh-CN/messages`) mod = await import(`./locales/zh-CN/messages`)
break break
} }
case AppLanguage.it: {
mod = await import(`./locales/it/messages`)
break
}
default: { default: {
mod = await import(`./locales/en/messages`) mod = await import(`./locales/en/messages`)
break break
+2
View File
@@ -17,6 +17,7 @@ export enum AppLanguage {
uk = 'uk', uk = 'uk',
ca = 'ca', ca = 'ca',
zh_CN = 'zh-CN', zh_CN = 'zh-CN',
it = 'it',
} }
interface AppLanguageConfig { interface AppLanguageConfig {
@@ -37,6 +38,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.uk, name: 'Українська Ukrainian'}, {code2: AppLanguage.uk, name: 'Українська Ukrainian'},
{code2: AppLanguage.ca, name: 'Català Catalan'}, {code2: AppLanguage.ca, name: 'Català Catalan'},
{code2: AppLanguage.zh_CN, name: '简体中文(中国) Chinese (Simplified)'}, {code2: AppLanguage.zh_CN, name: '简体中文(中国) Chinese (Simplified)'},
{code2: AppLanguage.it, name: 'Italiano - Italian'},
] ]
export const LANGUAGES: Language[] = [ export const LANGUAGES: Language[] = [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@ export const router = new Router({
DebugMod: '/sys/debug-mod', DebugMod: '/sys/debug-mod',
Log: '/sys/log', Log: '/sys/log',
AppPasswords: '/settings/app-passwords', AppPasswords: '/settings/app-passwords',
PreferencesHomeFeed: '/settings/home-feed', PreferencesFollowingFeed: '/settings/following-feed',
PreferencesThreads: '/settings/threads', PreferencesThreads: '/settings/threads',
PreferencesExternalEmbeds: '/settings/external-embeds', PreferencesExternalEmbeds: '/settings/external-embeds',
SavedFeeds: '/settings/saved-feeds', SavedFeeds: '/settings/saved-feeds',
+5 -3
View File
@@ -17,7 +17,7 @@ import {
flatten, flatten,
TextStyleProp, TextStyleProp,
} from '#/alf' } from '#/alf'
import {H2, P, leading} from '#/components/Typography' import {P, leading, Text} from '#/components/Typography'
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron' import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
import {Button, ButtonIcon} from '#/components/Button' import {Button, ButtonIcon} from '#/components/Button'
import {ScrollView} from '#/view/com/util/Views' import {ScrollView} from '#/view/com/util/Views'
@@ -209,16 +209,18 @@ export function Title({
style, style,
}: React.PropsWithChildren<TextStyleProp>) { }: React.PropsWithChildren<TextStyleProp>) {
return ( return (
<H2 <Text
style={[ style={[
a.pb_sm, a.pb_sm,
a.text_4xl,
a.font_bold,
{ {
lineHeight: leading(a.text_4xl, a.leading_tight), lineHeight: leading(a.text_4xl, a.leading_tight),
}, },
flatten(style), flatten(style),
]}> ]}>
{children} {children}
</H2> </Text>
) )
} }
@@ -20,7 +20,7 @@ import {
OnboardingControls, OnboardingControls,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
export type FeedConfig = { export type FeedConfig = {
default: boolean default: boolean
+1 -1
View File
@@ -23,7 +23,7 @@ import {
Description, Description,
OnboardingControls, OnboardingControls,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
import { import {
bulkWriteFollows, bulkWriteFollows,
sortPrimaryAlgorithmFeeds, sortPrimaryAlgorithmFeeds,
+1 -1
View File
@@ -22,7 +22,7 @@ import {
usePreferencesQuery, usePreferencesQuery,
useSetFeedViewPreferencesMutation, useSetFeedViewPreferencesMutation,
} from 'state/queries/preferences' } from 'state/queries/preferences'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
export function StepFollowingFeed() { export function StepFollowingFeed() {
const {_} = useLingui() const {_} = useLingui()
@@ -26,7 +26,7 @@ import {
OnboardingControls, OnboardingControls,
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton' import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
export function StepInterests() { export function StepInterests() {
const {_} = useLingui() const {_} = useLingui()
@@ -139,7 +139,7 @@ export function StepInterests() {
) )
return ( return (
<View style={[a.align_start]}> <View style={[a.align_start]} testID="onboardingInterests">
<IconCircle <IconCircle
icon={isError ? EmojiSad : Hashtag} icon={isError ? EmojiSad : Hashtag}
style={[ style={[
@@ -26,7 +26,7 @@ import {
import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption' import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref' import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
import {Context} from '#/screens/Onboarding/state' import {Context} from '#/screens/Onboarding/state'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
function AnimatedDivider() { function AnimatedDivider() {
return ( return (
@@ -27,7 +27,7 @@ import {
SuggestedAccountCardPlaceholder, SuggestedAccountCardPlaceholder,
} from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard' } from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util' import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
export function Inner({ export function Inner({
profiles, profiles,
+10 -4
View File
@@ -3,7 +3,6 @@ import {View} from 'react-native'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {IS_PROD} from '#/env'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass' import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
@@ -21,22 +20,29 @@ import {
} from '#/screens/Onboarding/Layout' } from '#/screens/Onboarding/Layout'
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard' import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util' import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {IconCircle} from '#/screens/Onboarding/IconCircle' import {IconCircle} from '#/components/IconCircle'
import {IS_PROD_SERVICE} from 'lib/constants'
import {useSession} from 'state/session'
export function StepTopicalFeeds() { export function StepTopicalFeeds() {
const {_} = useLingui() const {_} = useLingui()
const {track} = useAnalytics() const {track} = useAnalytics()
const {currentAccount} = useSession()
const {state, dispatch, interestsDisplayNames} = React.useContext(Context) const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([]) const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = React.useState(false)
const suggestedFeedUris = React.useMemo(() => { const suggestedFeedUris = React.useMemo(() => {
if (!IS_PROD) return [] if (!IS_PROD_SERVICE(currentAccount?.service)) return []
return aggregateInterestItems( return aggregateInterestItems(
state.interestsStepResults.selectedInterests, state.interestsStepResults.selectedInterests,
state.interestsStepResults.apiResponse.suggestedFeedUris, state.interestsStepResults.apiResponse.suggestedFeedUris,
state.interestsStepResults.apiResponse.suggestedFeedUris.default, state.interestsStepResults.apiResponse.suggestedFeedUris.default,
).slice(0, 10) ).slice(0, 10)
}, [state.interestsStepResults]) }, [
currentAccount?.service,
state.interestsStepResults.apiResponse.suggestedFeedUris,
state.interestsStepResults.selectedInterests,
])
const interestsText = React.useMemo(() => { const interestsText = React.useMemo(() => {
const i = state.interestsStepResults.selectedInterests.map( const i = state.interestsStepResults.selectedInterests.map(
+146
View File
@@ -0,0 +1,146 @@
import {Image as RNImage} from 'react-native-image-crop-picker'
import {makeAutoObservable} from 'mobx'
import {POST_IMG_MAX} from 'lib/constants'
import {ActionCrop} from 'expo-image-manipulator'
import {Position} from 'react-avatar-editor'
import {Dimensions} from 'lib/media/types'
export interface ImageManipulationAttributes {
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
rotate?: number
scale?: number
position?: Position
flipHorizontal?: boolean
flipVertical?: boolean
}
export class ImageModel implements Omit<RNImage, 'size'> {
path: string
mime = 'image/jpeg'
width: number
height: number
altText = ''
cropped?: RNImage = undefined
compressed?: RNImage = undefined
// Web manipulation
prev?: RNImage
attributes: ImageManipulationAttributes = {
aspectRatio: 'None',
scale: 1,
flipHorizontal: false,
flipVertical: false,
rotate: 0,
}
prevAttributes: ImageManipulationAttributes = {}
constructor(image: Omit<RNImage, 'size'>) {
makeAutoObservable(this)
this.path = image.path
this.width = image.width
this.height = image.height
}
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
this.attributes.aspectRatio = aspectRatio
}
setRotate(degrees: number) {
this.attributes.rotate = degrees
this.manipulate({})
}
flipVertical() {
this.attributes.flipVertical = !this.attributes.flipVertical
this.manipulate({})
}
flipHorizontal() {
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
this.manipulate({})
}
get ratioMultipliers() {
return {
'4:3': 4 / 3,
'1:1': 1,
'3:4': 3 / 4,
None: this.width / this.height,
}
}
getUploadDimensions(
dimensions: Dimensions,
maxDimensions: Dimensions = POST_IMG_MAX,
as: ImageManipulationAttributes['aspectRatio'] = 'None',
) {
const {width, height} = dimensions
const {width: maxWidth, height: maxHeight} = maxDimensions
return width < maxWidth && height < maxHeight
? {
width,
height,
}
: this.getResizedDimensions(as, POST_IMG_MAX.width)
}
getResizedDimensions(
as: ImageManipulationAttributes['aspectRatio'] = 'None',
maxSide: number,
) {
const ratioMultiplier = this.ratioMultipliers[as]
if (ratioMultiplier === 1) {
return {
height: maxSide,
width: maxSide,
}
}
if (ratioMultiplier < 1) {
return {
width: maxSide * ratioMultiplier,
height: maxSide,
}
}
return {
width: maxSide,
height: maxSide / ratioMultiplier,
}
}
setAltText(altText: string) {
this.altText = altText.trim()
}
// Only compress prior to upload
async compress() {
// do nothing
}
// Mobile
async crop() {
// do nothing
}
// Web manipulation
async manipulate(
_attributes: {
crop?: ActionCrop['crop']
} & ImageManipulationAttributes,
) {
// do nothing
}
resetCropped() {
this.manipulate({})
}
previous() {
this.cropped = this.prev
this.attributes = this.prevAttributes
}
}
-17
View File
@@ -133,23 +133,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
return query return query
} }
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): AppBskyFeedDefs.PostView | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData( export function* findAllPostsInQueryData(
queryClient: QueryClient, queryClient: QueryClient,
uri: string, uri: string,
-17
View File
@@ -376,23 +376,6 @@ function createApi(
} }
} }
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): AppBskyFeedDefs.PostView | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData( export function* findAllPostsInQueryData(
queryClient: QueryClient, queryClient: QueryClient,
uri: string, uri: string,
+40 -26
View File
@@ -8,8 +8,8 @@ import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
import {getAgent} from '#/state/session' import {getAgent} from '#/state/session'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {findPostInQueryData as findPostInFeedQueryData} from './post-feed' import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed' import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
import {precacheThreadPostProfiles} from './profile' import {precacheThreadPostProfiles} from './profile'
import {getEmbeddedPost} from './util' import {getEmbeddedPost} from './util'
@@ -82,21 +82,9 @@ export function usePostThreadQuery(uri: string | undefined) {
return undefined return undefined
} }
{ {
const item = findPostInQueryData(queryClient, uri) const post = findPostInQueryData(queryClient, uri)
if (item) { if (post) {
return threadNodeToPlaceholderThread(item) return post
}
}
{
const item = findPostInFeedQueryData(queryClient, uri)
if (item) {
return postViewToPlaceholderThread(item)
}
}
{
const item = findPostInNotifsQueryData(queryClient, uri)
if (item) {
return postViewToPlaceholderThread(item)
} }
} }
return undefined return undefined
@@ -171,11 +159,18 @@ function responseToThreadNodes(
AppBskyFeedPost.isRecord(node.post.record) && AppBskyFeedPost.isRecord(node.post.record) &&
AppBskyFeedPost.validateRecord(node.post.record).success AppBskyFeedPost.validateRecord(node.post.record).success
) { ) {
const post = node.post
// These should normally be present. They're missing only for
// posts that were *just* created. Ideally, the backend would
// know to return zeros. Fill them in manually to compensate.
post.replyCount ??= 0
post.likeCount ??= 0
post.repostCount ??= 0
return { return {
type: 'post', type: 'post',
_reactKey: node.post.uri, _reactKey: node.post.uri,
uri: node.post.uri, uri: node.post.uri,
post: node.post, post: post,
record: node.post.record, record: node.post.record,
parent: parent:
node.parent && direction !== 'down' node.parent && direction !== 'down'
@@ -213,14 +208,24 @@ function responseToThreadNodes(
function findPostInQueryData( function findPostInQueryData(
queryClient: QueryClient, queryClient: QueryClient,
uri: string, uri: string,
): ThreadNode | undefined { ): ThreadNode | void {
const generator = findAllPostsInQueryData(queryClient, uri) let partial
const result = generator.next() for (let item of findAllPostsInQueryData(queryClient, uri)) {
if (result.done) { if (item.type === 'post') {
return undefined // Currently, the backend doesn't send full post info in some cases
} else { // (for example, for quoted posts). We use missing `likeCount`
return result.value // as a way to detect that. In the future, we should fix this on
// the backend, which will let us always stop on the first result.
const hasAllInfo = item.post.likeCount != null
if (hasAllInfo) {
return item
} else {
partial = item
// Keep searching, we might still find a full post in the cache.
}
}
} }
return partial
} }
export function* findAllPostsInQueryData( export function* findAllPostsInQueryData(
@@ -236,7 +241,10 @@ export function* findAllPostsInQueryData(
} }
for (const item of traverseThread(queryData)) { for (const item of traverseThread(queryData)) {
if (item.uri === uri) { if (item.uri === uri) {
yield item const placeholder = threadNodeToPlaceholderThread(item)
if (placeholder) {
yield placeholder
}
} }
const quotedPost = const quotedPost =
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
@@ -245,6 +253,12 @@ export function* findAllPostsInQueryData(
} }
} }
} }
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
yield postViewToPlaceholderThread(post)
}
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
yield postViewToPlaceholderThread(post)
}
} }
function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> { function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
+1
View File
@@ -53,5 +53,6 @@ export function embedViewRecordToPostView(
record: v.value, record: v.value,
indexedAt: v.indexedAt, indexedAt: v.indexedAt,
labels: v.labels, labels: v.labels,
embed: v.embeds?.[0],
} }
} }
-6
View File
@@ -7,7 +7,6 @@ import {networkRetry} from '#/lib/async/retry'
import {logger} from '#/logger' import {logger} from '#/logger'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {PUBLIC_BSKY_AGENT} from '#/state/queries' import {PUBLIC_BSKY_AGENT} from '#/state/queries'
import {IS_PROD} from '#/lib/constants'
import {emitSessionDropped} from '../events' import {emitSessionDropped} from '../events'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
@@ -36,7 +35,6 @@ export type SessionState = {
} }
export type StateContext = SessionState & { export type StateContext = SessionState & {
hasSession: boolean hasSession: boolean
isSandbox: boolean
} }
export type ApiContext = { export type ApiContext = {
createAccount: (props: { createAccount: (props: {
@@ -84,7 +82,6 @@ const StateContext = React.createContext<StateContext>({
accounts: [], accounts: [],
currentAccount: undefined, currentAccount: undefined,
hasSession: false, hasSession: false,
isSandbox: false,
}) })
const ApiContext = React.createContext<ApiContext>({ const ApiContext = React.createContext<ApiContext>({
@@ -612,9 +609,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({ () => ({
...state, ...state,
hasSession: !!state.currentAccount, hasSession: !!state.currentAccount,
isSandbox: state.currentAccount
? !IS_PROD(state.currentAccount?.service)
: false,
}), }),
[state], [state],
) )
+78 -1
View File
@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
@@ -9,6 +10,14 @@ import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype' import {Logotype} from '#/view/icons/Logotype'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {APP_LANGUAGES} from '#/locale/languages'
export const SplashScreen = ({ export const SplashScreen = ({
onPressSignin, onPressSignin,
@@ -20,6 +29,22 @@ export const SplashScreen = ({
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const insets = useSafeAreaInsets()
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
const onChangeAppLanguage = React.useCallback(
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
if (!value) return
if (sanitizedLang !== value) {
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
}
},
[sanitizedLang, setLangPrefs],
)
return ( return (
<CenteredView style={[styles.container, pal.view]}> <CenteredView style={[styles.container, pal.view]}>
<ErrorBoundary> <ErrorBoundary>
@@ -58,6 +83,51 @@ export const SplashScreen = ({
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.footer}>
<View style={{position: 'relative'}}>
<RNPickerSelect
placeholder={{}}
value={sanitizedLang}
onValueChange={onChangeAppLanguage}
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
useNativeAndroidPickerStyle={false}
style={{
inputAndroid: {
color: pal.textLight.color,
fontSize: 16,
paddingRight: 10 + 4,
},
inputIOS: {
color: pal.text.color,
fontSize: 16,
paddingRight: 10 + 4,
},
}}
/>
<View
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<FontAwesomeIcon
icon="chevron-down"
size={10}
style={pal.textLight as FontAwesomeIconStyle}
/>
</View>
</View>
</View>
<View style={{height: insets.bottom}} />
</ErrorBoundary> </ErrorBoundary>
</CenteredView> </CenteredView>
) )
@@ -73,7 +143,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
btns: { btns: {
paddingBottom: 40, paddingBottom: 0,
}, },
title: { title: {
textAlign: 'center', textAlign: 'center',
@@ -95,4 +165,11 @@ const styles = StyleSheet.create({
textAlign: 'center', textAlign: 'center',
fontSize: 21, fontSize: 21,
}, },
footer: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 24,
justifyContent: 'center',
alignItems: 'center',
},
}) })
+72 -7
View File
@@ -9,9 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
import {CenteredView} from '../util/Views' import {CenteredView} from '../util/Views'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Trans} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype' import {Logotype} from '#/view/icons/Logotype'
import {useLingui} from '@lingui/react'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {APP_LANGUAGES} from '#/locale/languages'
export const SplashScreen = ({ export const SplashScreen = ({
onDismiss, onDismiss,
@@ -98,24 +102,84 @@ export const SplashScreen = ({
function Footer({styles}: {styles: ReturnType<typeof useStyles>}) { function Footer({styles}: {styles: ReturnType<typeof useStyles>}) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
const onChangeAppLanguage = React.useCallback(
(ev: React.ChangeEvent<HTMLSelectElement>) => {
const value = ev.target.value
if (!value) return
if (sanitizedLang !== value) {
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
}
},
[sanitizedLang, setLangPrefs],
)
return ( return (
<View style={[styles.footer, pal.view, pal.border]}> <View style={[styles.footer, pal.view, pal.border]}>
<TextLink <TextLink
href="https://bsky.social" href="https://bsky.social"
text="Business" text={_(msg`Business`)}
style={[styles.footerLink, pal.link]} style={[styles.footerLink, pal.link]}
/> />
<TextLink <TextLink
href="https://bsky.social/about/blog" href="https://bsky.social/about/blog"
text="Blog" text={_(msg`Blog`)}
style={[styles.footerLink, pal.link]} style={[styles.footerLink, pal.link]}
/> />
<TextLink <TextLink
href="https://bsky.social/about/join" href="https://bsky.social/about/join"
text="Jobs" text={_(msg`Jobs`)}
style={[styles.footerLink, pal.link]} style={[styles.footerLink, pal.link]}
/> />
<View style={styles.footerDivider} />
<View
style={{
flexDirection: 'row',
gap: 8,
alignItems: 'center',
flexShrink: 1,
}}>
<Text aria-hidden={true} style={[pal.textLight]}>
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
</Text>
<FontAwesomeIcon
icon="chevron-down"
size={12}
style={[pal.textLight, {flexShrink: 0}]}
/>
<select
value={sanitizedLang}
onChange={onChangeAppLanguage}
style={{
cursor: 'pointer',
MozAppearance: 'none',
WebkitAppearance: 'none',
appearance: 'none',
position: 'absolute',
inset: 0,
width: '100%',
color: 'transparent',
background: 'transparent',
border: 0,
padding: 0,
}}>
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
<option key={l.code2} value={l.code2}>
{l.name}
</option>
))}
</select>
</View>
</View> </View>
) )
} }
@@ -188,9 +252,10 @@ const useStyles = () => {
padding: 20, padding: 20,
borderTopWidth: 1, borderTopWidth: 1,
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap',
gap: 20,
}, },
footerLink: { footerDivider: {flexGrow: 1},
marginRight: 20, footerLink: {},
},
}) })
} }
@@ -0,0 +1,86 @@
import React from 'react'
import {WebView, WebViewNavigation} from 'react-native-webview'
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
import {StyleSheet} from 'react-native'
import {CreateAccountState} from 'view/com/auth/create/state'
const ALLOWED_HOSTS = [
'bsky.social',
'bsky.app',
'staging.bsky.app',
'staging.bsky.dev',
'js.hcaptcha.com',
'newassets.hcaptcha.com',
'api2.hcaptcha.com',
]
export function CaptchaWebView({
url,
stateParam,
uiState,
onSuccess,
onError,
}: {
url: string
stateParam: string
uiState?: CreateAccountState
onSuccess: (code: string) => void
onError: () => void
}) {
const redirectHost = React.useMemo(() => {
if (!uiState?.serviceUrl) return 'bsky.app'
return uiState?.serviceUrl &&
new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
? 'staging.bsky.app'
: 'bsky.app'
}, [uiState?.serviceUrl])
const wasSuccessful = React.useRef(false)
const onShouldStartLoadWithRequest = React.useCallback(
(event: ShouldStartLoadRequest) => {
const urlp = new URL(event.url)
return ALLOWED_HOSTS.includes(urlp.host)
},
[],
)
const onNavigationStateChange = React.useCallback(
(e: WebViewNavigation) => {
if (wasSuccessful.current) return
const urlp = new URL(e.url)
if (urlp.host !== redirectHost) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
wasSuccessful.current = true
onSuccess(code)
},
[redirectHost, stateParam, onSuccess, onError],
)
return (
<WebView
source={{uri: url}}
javaScriptEnabled
style={styles.webview}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
scrollEnabled={false}
/>
)
}
const styles = StyleSheet.create({
webview: {
flex: 1,
backgroundColor: 'transparent',
borderRadius: 10,
},
})
@@ -0,0 +1,61 @@
import React from 'react'
import {StyleSheet} from 'react-native'
// @ts-ignore web only, we will always redirect to the app on web (CORS)
const REDIRECT_HOST = new URL(window.location.href).host
export function CaptchaWebView({
url,
stateParam,
onSuccess,
onError,
}: {
url: string
stateParam: string
onSuccess: (code: string) => void
onError: () => void
}) {
const onLoad = React.useCallback(() => {
// @ts-ignore web
const frame: HTMLIFrameElement = document.getElementById(
'captcha-iframe',
) as HTMLIFrameElement
try {
// @ts-ignore web
const href = frame?.contentWindow?.location.href
if (!href) return
const urlp = new URL(href)
// This shouldn't happen with CORS protections, but for good measure
if (urlp.host !== REDIRECT_HOST) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
onSuccess(code)
} catch (e) {
// We don't need to handle this
}
}, [stateParam, onSuccess, onError])
return (
<iframe
src={url}
style={styles.iframe}
id="captcha-iframe"
onLoad={onLoad}
/>
)
}
const styles = StyleSheet.create({
iframe: {
flex: 1,
borderWidth: 0,
borderRadius: 10,
backgroundColor: 'transparent',
},
})
+43 -32
View File
@@ -13,33 +13,25 @@ import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useOnboardingDispatch} from '#/state/shell' import {useCreateAccount, useSubmitCreateAccount} from './state'
import {useSessionApi} from '#/state/session'
import {useCreateAccount, submit} from './state'
import {useServiceQuery} from '#/state/queries/service' import {useServiceQuery} from '#/state/queries/service'
import { import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
usePreferencesSetBirthDateMutation,
useSetSaveFeedsMutation,
DEFAULT_PROD_FEEDS,
} from '#/state/queries/preferences'
import {FEEDBACK_FORM_URL, HITSLOP_10, IS_PROD} from '#/lib/constants'
import {Step1} from './Step1' import {Step1} from './Step1'
import {Step2} from './Step2' import {Step2} from './Step2'
import {Step3} from './Step3' import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {TextLink} from '../../util/Link' import {TextLink} from '../../util/Link'
import {getAgent} from 'state/session'
import {createFullHandle, validateHandle} from 'lib/strings/handles'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) { export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics() const {screen} = useAnalytics()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const [uiState, uiDispatch] = useCreateAccount() const [uiState, uiDispatch] = useCreateAccount()
const onboardingDispatch = useOnboardingDispatch()
const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
const submit = useSubmitCreateAccount(uiState, uiDispatch)
React.useEffect(() => { React.useEffect(() => {
screen('CreateAccount') screen('CreateAccount')
@@ -84,33 +76,52 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
if (!uiState.canNext) { if (!uiState.canNext) {
return return
} }
if (uiState.step < 3) {
uiDispatch({type: 'next'}) if (uiState.step === 2) {
} else { if (!validateHandle(uiState.handle, uiState.userDomain).overall) {
return
}
uiDispatch({type: 'set-processing', value: true})
try { try {
await submit({ const res = await getAgent().resolveHandle({
onboardingDispatch, handle: createFullHandle(uiState.handle, uiState.userDomain),
createAccount,
uiState,
uiDispatch,
_,
}) })
setBirthDate({birthDate: uiState.birthDate})
if (IS_PROD(uiState.serviceUrl)) { if (res.data.did) {
setSavedFeeds(DEFAULT_PROD_FEEDS) uiDispatch({
type: 'set-error',
value: _(msg`That handle is already taken.`),
})
return
} }
} catch { } catch (e) {
// dont need to handle here // Don't need to handle
} finally {
uiDispatch({type: 'set-processing', value: false})
}
if (!uiState.isCaptchaRequired) {
try {
await submit()
} catch {
// dont need to handle here
}
// We don't need to go to the next page if there wasn't a captcha required
return
} }
} }
uiDispatch({type: 'next'})
}, [ }, [
uiState, uiState.canNext,
uiState.step,
uiState.isCaptchaRequired,
uiState.handle,
uiState.userDomain,
uiDispatch, uiDispatch,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
_, _,
submit,
]) ])
// rendering // rendering
+6 -5
View File
@@ -73,6 +73,10 @@ export function Step1({
/> />
<StepHeader uiState={uiState} title={_(msg`Your account`)} /> <StepHeader uiState={uiState} title={_(msg`Your account`)} />
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
<View style={s.pb20}> <View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}> <Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Hosting provider</Trans> <Trans>Hosting provider</Trans>
@@ -88,7 +92,7 @@ export function Step1({
style={[pal.textLight, {marginLeft: 14}]} style={[pal.textLight, {marginLeft: 14}]}
/> />
<TouchableOpacity <TouchableOpacity
testID="loginSelectServiceButton" testID="selectServiceButton"
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
flex: 1, flex: 1,
@@ -259,9 +263,6 @@ export function Step1({
)} )}
</> </>
)} )}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
</View> </View>
) )
} }
@@ -269,7 +270,7 @@ export function Step1({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
error: { error: {
borderRadius: 6, borderRadius: 6,
marginTop: 10, marginBottom: 10,
}, },
dateInputButton: { dateInputButton: {
borderWidth: 1, borderWidth: 1,
+106 -273
View File
@@ -1,35 +1,26 @@
import React from 'react' import React from 'react'
import { import {View} from 'react-native'
ActivityIndicator, import {CreateAccountState, CreateAccountDispatch} from './state'
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import RNPickerSelect from 'react-native-picker-select'
import {
CreateAccountState,
CreateAccountDispatch,
requestVerificationCode,
} from './state'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {StepHeader} from './StepHeader' import {StepHeader} from './StepHeader'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput' import {TextInput} from '../util/TextInput'
import {Button} from '../../util/forms/Button'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isAndroid, isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import parsePhoneNumber from 'libphonenumber-js'
import {COUNTRY_CODES} from '#/lib/country-codes'
import { import {
FontAwesomeIcon, createFullHandle,
FontAwesomeIconStyle, IsValidHandle,
} from '@fortawesome/react-native-fontawesome' validateHandle,
import {HITSLOP_10} from '#/lib/constants' } from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {useFocusEffect} from '@react-navigation/native'
/** STEP 3: Your user handle
* @field User handle
*/
export function Step2({ export function Step2({
uiState, uiState,
uiDispatch, uiDispatch,
@@ -39,269 +30,111 @@ export function Step2({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {isMobile} = useWebMediaQueries() const t = useTheme()
const onPressRequest = React.useCallback(() => { const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
const phoneNumber = parsePhoneNumber( handleChars: false,
uiState.verificationPhone, frontLength: false,
uiState.phoneCountry, totalLength: true,
) overall: false,
if (phoneNumber && phoneNumber.isValid()) { })
requestVerificationCode({uiState, uiDispatch, _})
} else {
uiDispatch({
type: 'set-error',
value: _(
msg`There's something wrong with this number. Please choose your country and enter your full phone number!`,
),
})
}
}, [uiState, uiDispatch, _])
const onPressRetry = React.useCallback(() => { useFocusEffect(
uiDispatch({type: 'set-has-requested-verification-code', value: false}) React.useCallback(() => {
}, [uiDispatch]) setValidCheck(validateHandle(uiState.handle, uiState.userDomain))
const phoneNumberFormatted = React.useMemo( // Disabling this, because we only want to run this when we focus the screen
() => // eslint-disable-next-line react-hooks/exhaustive-deps
uiState.hasRequestedVerificationCode }, []),
? parsePhoneNumber( )
uiState.verificationPhone,
uiState.phoneCountry, const onHandleChange = React.useCallback(
)?.formatInternational() (value: string) => {
: '', if (uiState.error) {
[ uiDispatch({type: 'set-error', value: ''})
uiState.hasRequestedVerificationCode, }
uiState.verificationPhone,
uiState.phoneCountry, setValidCheck(validateHandle(value, uiState.userDomain))
], uiDispatch({type: 'set-handle', value})
},
[uiDispatch, uiState.error, uiState.userDomain],
) )
return ( return (
<View> <View>
<StepHeader uiState={uiState} title={_(msg`SMS verification`)} /> <StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
<View style={s.pb10}>
{!uiState.hasRequestedVerificationCode ? ( <View style={s.mb20}>
<> <TextInput
<View style={s.pb10}> testID="handleInput"
<Text icon="at"
type="md-medium" placeholder="e.g. alice"
style={[pal.text, s.mb2]} value={uiState.handle}
nativeID="phoneCountry"> editable
<Trans>Country</Trans> autoFocus
autoComplete="off"
autoCorrect={false}
onChange={onHandleChange}
// TODO: Add explicit text label
accessibilityLabel={_(msg`User handle`)}
accessibilityHint={_(msg`Input your user handle`)}
/>
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
<Trans>Your full handle will be</Trans>{' '}
<Text type="lg-bold" style={pal.text}>
@{createFullHandle(uiState.handle, uiState.userDomain)}
</Text> </Text>
<View </Text>
style={[ </View>
{position: 'relative'}, <View
isAndroid && { style={[
borderWidth: 1, a.w_full,
borderColor: pal.border.borderColor, a.rounded_sm,
borderRadius: 4, a.border,
}, a.p_md,
]}> a.gap_sm,
<RNPickerSelect t.atoms.border_contrast_low,
placeholder={{}} ]}>
value={uiState.phoneCountry} {uiState.error ? (
onValueChange={value => <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
uiDispatch({type: 'set-phone-country', value}) <IsValidIcon valid={false} />
} <Text style={[t.atoms.text, a.text_md, a.flex]}>
items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({ {uiState.error}
label: l.name, </Text>
value: l.code2,
key: l.code2,
}))}
style={{
inputAndroid: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 21,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
inputIOS: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderWidth: 1,
borderColor: pal.border.borderColor,
borderRadius: 4,
},
inputWeb: {
// @ts-ignore web only
cursor: 'pointer',
'-moz-appearance': 'none',
'-webkit-appearance': 'none',
appearance: 'none',
outline: 0,
borderWidth: 1,
borderColor: pal.border.borderColor,
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
}}
accessibilityLabel={_(msg`Select your phone's country`)}
accessibilityHint=""
accessibilityLabelledBy="phoneCountry"
/>
<View
style={{
position: 'absolute',
top: 1,
right: 1,
bottom: 1,
width: 40,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<FontAwesomeIcon
icon="chevron-down"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
</View> </View>
</View> ) : undefined}
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<View style={s.pb20}> <IsValidIcon valid={validCheck.handleChars} />
<Text <Text style={[t.atoms.text, a.text_md, a.flex]}>
type="md-medium" <Trans>May only contain letters and numbers</Trans>
style={[pal.text, s.mb2]}
nativeID="phoneNumber">
<Trans>Phone number</Trans>
</Text> </Text>
<TextInput </View>
testID="phoneInput" <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
icon="phone" <IsValidIcon
placeholder={_(msg`Enter your phone number`)} valid={validCheck.frontLength && validCheck.totalLength}
value={uiState.verificationPhone}
editable
onChange={value =>
uiDispatch({type: 'set-verification-phone', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input phone number for SMS verification`,
)}
accessibilityLabelledBy="phoneNumber"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="tel"
autoCorrect={false}
autoFocus={true}
/> />
<Text type="sm" style={[pal.textLight, s.mt5]}> {!validCheck.totalLength ? (
<Trans> <Text style={[t.atoms.text]}>
Please enter a phone number that can receive SMS text messages. <Trans>May not be longer than 253 characters</Trans>
</Trans> </Text>
</Text>
</View>
<View style={isMobile ? {} : {flexDirection: 'row'}}>
{uiState.isProcessing ? (
<ActivityIndicator />
) : ( ) : (
<Button <Text style={[t.atoms.text, a.text_md]}>
testID="requestCodeBtn" <Trans>Must be at least 3 characters</Trans>
type="primary" </Text>
label={_(msg`Request code`)}
labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
style={
isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
}
onPress={onPressRequest}
/>
)} )}
</View> </View>
</> </View>
) : ( </View>
<>
<View style={s.pb20}>
<View
style={[
s.flexRow,
s.mb5,
s.alignCenter,
{justifyContent: 'space-between'},
]}>
<Text
type="md-medium"
style={pal.text}
nativeID="verificationCode">
<Trans>Verification code</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressRetry}
accessibilityLabel={_(msg`Retry.`)}
accessibilityHint=""
hitSlop={HITSLOP_10}>
<View style={styles.touchable}>
<Text
type="md-medium"
style={pal.link}
nativeID="verificationCode">
<Trans>Retry</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
<TextInput
testID="codeInput"
icon="hashtag"
placeholder={_(msg`XXXXXX`)}
value={uiState.verificationCode}
editable
onChange={value =>
uiDispatch({type: 'set-verification-code', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input the verification code we have texted to you`,
)}
accessibilityLabelledBy="verificationCode"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="one-time-code"
textContentType="oneTimeCode"
autoCorrect={false}
autoFocus={true}
/>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
Please enter the verification code sent to{' '}
{phoneNumberFormatted}.
</Trans>
</Text>
</View>
</>
)}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
</View> </View>
) )
} }
const styles = StyleSheet.create({ function IsValidIcon({valid}: {valid: boolean}) {
error: { const t = useTheme()
borderRadius: 6,
marginTop: 10, if (!valid) {
}, return <Check size="md" style={{color: t.palette.negative_500}} />
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832. }
touchable: {
...(isWeb && {cursor: 'pointer'}), return <Times size="md" style={{color: t.palette.positive_700}} />
}, }
})
+86 -34
View File
@@ -1,19 +1,23 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {CreateAccountState, CreateAccountDispatch} from './state' import {
import {Text} from 'view/com/util/text/Text' CreateAccountState,
CreateAccountDispatch,
useSubmitCreateAccount,
} from './state'
import {StepHeader} from './StepHeader' import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {TextInput} from '../util/TextInput'
import {createFullHandle} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {msg, Trans} from '@lingui/macro' import {isWeb} from 'platform/detection'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
/** STEP 3: Your user handle import {nanoid} from 'nanoid/non-secure'
* @field User handle import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
*/ import {useTheme} from 'lib/ThemeContext'
import {createFullHandle} from 'lib/strings/handles'
const CAPTCHA_PATH = '/gate/signup'
export function Step3({ export function Step3({
uiState, uiState,
uiDispatch, uiDispatch,
@@ -21,33 +25,66 @@ export function Step3({
uiState: CreateAccountState uiState: CreateAccountState
uiDispatch: CreateAccountDispatch uiDispatch: CreateAccountDispatch
}) { }) {
const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const theme = useTheme()
const submit = useSubmitCreateAccount(uiState, uiDispatch)
const [completed, setCompleted] = React.useState(false)
const stateParam = React.useMemo(() => nanoid(15), [])
const url = React.useMemo(() => {
const newUrl = new URL(uiState.serviceUrl)
newUrl.pathname = CAPTCHA_PATH
newUrl.searchParams.set(
'handle',
createFullHandle(uiState.handle, uiState.userDomain),
)
newUrl.searchParams.set('state', stateParam)
newUrl.searchParams.set('colorScheme', theme.colorScheme)
console.log(newUrl)
return newUrl.href
}, [
uiState.serviceUrl,
uiState.handle,
uiState.userDomain,
stateParam,
theme.colorScheme,
])
const onSuccess = React.useCallback(
(code: string) => {
setCompleted(true)
submit(code)
},
[submit],
)
const onError = React.useCallback(() => {
uiDispatch({
type: 'set-error',
value: _(msg`Error receiving captcha response.`),
})
}, [_, uiDispatch])
return ( return (
<View> <View>
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} /> <StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
<View style={s.pb10}> <View style={[styles.container, completed && styles.center]}>
<TextInput {!completed ? (
testID="handleInput" <CaptchaWebView
icon="at" url={url}
placeholder="e.g. alice" stateParam={stateParam}
value={uiState.handle} uiState={uiState}
editable onSuccess={onSuccess}
autoFocus onError={onError}
autoComplete="off" />
autoCorrect={false} ) : (
onChange={value => uiDispatch({type: 'set-handle', value})} <ActivityIndicator size="large" />
// TODO: Add explicit text label )}
accessibilityLabel={_(msg`User handle`)}
accessibilityHint={_(msg`Input your user handle`)}
/>
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
<Trans>Your full handle will be</Trans>{' '}
<Text type="lg-bold" style={pal.text}>
@{createFullHandle(uiState.handle, uiState.userDomain)}
</Text>
</Text>
</View> </View>
{uiState.error ? ( {uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} /> <ErrorMessage message={uiState.error} style={styles.error} />
) : undefined} ) : undefined}
@@ -58,5 +95,20 @@ export function Step3({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
error: { error: {
borderRadius: 6, borderRadius: 6,
marginTop: 10,
},
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
touchable: {
...(isWeb && {cursor: 'pointer'}),
},
container: {
minHeight: 500,
width: '100%',
paddingBottom: 20,
overflow: 'hidden',
},
center: {
alignItems: 'center',
justifyContent: 'center',
}, },
}) })
+1 -1
View File
@@ -11,7 +11,7 @@ export function StepHeader({
children, children,
}: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) { }: React.PropsWithChildren<{uiState: CreateAccountState; title: string}>) {
const pal = usePalette('default') const pal = usePalette('default')
const numSteps = uiState.isPhoneVerificationRequired ? 3 : 2 const numSteps = 3
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View> <View>
+125 -177
View File
@@ -1,20 +1,23 @@
import {useReducer} from 'react' import {useCallback, useReducer} from 'react'
import { import {
ComAtprotoServerDescribeServer, ComAtprotoServerDescribeServer,
ComAtprotoServerCreateAccount, ComAtprotoServerCreateAccount,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {I18nContext, useLingui} from '@lingui/react' import {I18nContext, useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import * as EmailValidator from 'email-validator' import * as EmailValidator from 'email-validator'
import {getAge} from 'lib/strings/time' import {getAge} from 'lib/strings/time'
import {logger} from '#/logger' import {logger} from '#/logger'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle, validateHandle} from '#/lib/strings/handles'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' import {useOnboardingDispatch} from '#/state/shell/onboarding'
import {ApiContext as SessionApiContext} from '#/state/session' import {useSessionApi} from '#/state/session'
import {DEFAULT_SERVICE} from '#/lib/constants' import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
import parsePhoneNumber, {CountryCode} from 'libphonenumber-js' import {
DEFAULT_PROD_FEEDS,
usePreferencesSetBirthDateMutation,
useSetSaveFeedsMutation,
} from 'state/queries/preferences'
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
@@ -29,10 +32,6 @@ export type CreateAccountAction =
| {type: 'set-invite-code'; value: string} | {type: 'set-invite-code'; value: string}
| {type: 'set-email'; value: string} | {type: 'set-email'; value: string}
| {type: 'set-password'; value: string} | {type: 'set-password'; value: string}
| {type: 'set-phone-country'; value: CountryCode}
| {type: 'set-verification-phone'; value: string}
| {type: 'set-verification-code'; value: string}
| {type: 'set-has-requested-verification-code'; value: boolean}
| {type: 'set-handle'; value: string} | {type: 'set-handle'; value: string}
| {type: 'set-birth-date'; value: Date} | {type: 'set-birth-date'; value: Date}
| {type: 'next'} | {type: 'next'}
@@ -49,10 +48,6 @@ export interface CreateAccountState {
inviteCode: string inviteCode: string
email: string email: string
password: string password: string
phoneCountry: CountryCode
verificationPhone: string
verificationCode: string
hasRequestedVerificationCode: boolean
handle: string handle: string
birthDate: Date birthDate: Date
@@ -60,13 +55,14 @@ export interface CreateAccountState {
canBack: boolean canBack: boolean
canNext: boolean canNext: boolean
isInviteCodeRequired: boolean isInviteCodeRequired: boolean
isPhoneVerificationRequired: boolean isCaptchaRequired: boolean
} }
export type CreateAccountDispatch = (action: CreateAccountAction) => void export type CreateAccountDispatch = (action: CreateAccountAction) => void
export function useCreateAccount() { export function useCreateAccount() {
const {_} = useLingui() const {_} = useLingui()
return useReducer(createReducer({_}), { return useReducer(createReducer({_}), {
step: 1, step: 1,
error: undefined, error: undefined,
@@ -77,144 +73,126 @@ export function useCreateAccount() {
inviteCode: '', inviteCode: '',
email: '', email: '',
password: '', password: '',
phoneCountry: 'US',
verificationPhone: '',
verificationCode: '',
hasRequestedVerificationCode: false,
handle: '', handle: '',
birthDate: DEFAULT_DATE, birthDate: DEFAULT_DATE,
canBack: false, canBack: false,
canNext: false, canNext: false,
isInviteCodeRequired: false, isInviteCodeRequired: false,
isPhoneVerificationRequired: false, isCaptchaRequired: false,
}) })
} }
export async function requestVerificationCode({ export function useSubmitCreateAccount(
uiState, uiState: CreateAccountState,
uiDispatch, uiDispatch: CreateAccountDispatch,
_, ) {
}: { const {_} = useLingui()
uiState: CreateAccountState const {createAccount} = useSessionApi()
uiDispatch: CreateAccountDispatch const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
_: I18nContext['_'] const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
}) { const onboardingDispatch = useOnboardingDispatch()
const phoneNumber = parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)?.number
if (!phoneNumber) {
return
}
uiDispatch({type: 'set-error', value: ''})
uiDispatch({type: 'set-processing', value: true})
uiDispatch({type: 'set-verification-phone', value: phoneNumber})
try {
const agent = new BskyAgent({service: uiState.serviceUrl})
await agent.com.atproto.temp.requestPhoneVerification({
phoneNumber,
})
uiDispatch({type: 'set-has-requested-verification-code', value: true})
} catch (e: any) {
logger.error(
`Failed to request sms verification code (${e.status} status)`,
{message: e},
)
uiDispatch({type: 'set-error', value: cleanError(e.toString())})
}
uiDispatch({type: 'set-processing', value: false})
}
export async function submit({ return useCallback(
createAccount, async (verificationCode?: string) => {
onboardingDispatch, if (!uiState.email) {
uiState, uiDispatch({type: 'set-step', value: 1})
uiDispatch, console.log('no email?')
_, return uiDispatch({
}: { type: 'set-error',
createAccount: SessionApiContext['createAccount'] value: _(msg`Please enter your email.`),
onboardingDispatch: OnboardingDispatchContext })
uiState: CreateAccountState }
uiDispatch: CreateAccountDispatch if (!EmailValidator.validate(uiState.email)) {
_: I18nContext['_'] uiDispatch({type: 'set-step', value: 1})
}) { return uiDispatch({
if (!uiState.email) { type: 'set-error',
uiDispatch({type: 'set-step', value: 1}) value: _(msg`Your email appears to be invalid.`),
return uiDispatch({ })
type: 'set-error', }
value: _(msg`Please enter your email.`), if (!uiState.password) {
}) uiDispatch({type: 'set-step', value: 1})
} return uiDispatch({
if (!EmailValidator.validate(uiState.email)) { type: 'set-error',
uiDispatch({type: 'set-step', value: 1}) value: _(msg`Please choose your password.`),
return uiDispatch({ })
type: 'set-error', }
value: _(msg`Your email appears to be invalid.`), if (!uiState.handle) {
}) uiDispatch({type: 'set-step', value: 2})
} return uiDispatch({
if (!uiState.password) { type: 'set-error',
uiDispatch({type: 'set-step', value: 1}) value: _(msg`Please choose your handle.`),
return uiDispatch({ })
type: 'set-error', }
value: _(msg`Please choose your password.`), if (uiState.isCaptchaRequired && !verificationCode) {
}) uiDispatch({type: 'set-step', value: 3})
} return uiDispatch({
if ( type: 'set-error',
uiState.isPhoneVerificationRequired && value: _(msg`Please complete the verification captcha.`),
(!uiState.verificationPhone || !uiState.verificationCode) })
) { }
uiDispatch({type: 'set-step', value: 2}) uiDispatch({type: 'set-error', value: ''})
return uiDispatch({ uiDispatch({type: 'set-processing', value: true})
type: 'set-error',
value: _(msg`Please enter the code you received by SMS.`),
})
}
if (!uiState.handle) {
uiDispatch({type: 'set-step', value: 3})
return uiDispatch({
type: 'set-error',
value: _(msg`Please choose your handle.`),
})
}
uiDispatch({type: 'set-error', value: ''})
uiDispatch({type: 'set-processing', value: true})
try { try {
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await createAccount({ await createAccount({
service: uiState.serviceUrl, service: uiState.serviceUrl,
email: uiState.email, email: uiState.email,
handle: createFullHandle(uiState.handle, uiState.userDomain), handle: createFullHandle(uiState.handle, uiState.userDomain),
password: uiState.password, password: uiState.password,
inviteCode: uiState.inviteCode.trim(), inviteCode: uiState.inviteCode.trim(),
verificationPhone: uiState.verificationPhone.trim(), verificationCode: uiState.isCaptchaRequired
verificationCode: uiState.verificationCode.trim(), ? verificationCode
}) : undefined,
} catch (e: any) { })
onboardingDispatch({type: 'skip'}) // undo starting the onboard setBirthDate({birthDate: uiState.birthDate})
let errMsg = e.toString() if (IS_PROD_SERVICE(uiState.serviceUrl)) {
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { setSavedFeeds(DEFAULT_PROD_FEEDS)
errMsg = _( }
msg`Invite code not accepted. Check that you input it correctly and try again.`, } catch (e: any) {
) onboardingDispatch({type: 'skip'}) // undo starting the onboard
uiDispatch({type: 'set-step', value: 1}) let errMsg = e.toString()
} else if (e.error === 'InvalidPhoneVerification') { if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
uiDispatch({type: 'set-step', value: 2}) errMsg = _(
} msg`Invite code not accepted. Check that you input it correctly and try again.`,
)
uiDispatch({type: 'set-step', value: 1})
}
if ([400, 429].includes(e.status)) { if ([400, 429].includes(e.status)) {
logger.warn('Failed to create account', {message: e}) logger.warn('Failed to create account', {message: e})
} else { } else {
logger.error(`Failed to create account (${e.status} status)`, { logger.error(`Failed to create account (${e.status} status)`, {
message: e, message: e,
}) })
} }
uiDispatch({type: 'set-processing', value: false}) const error = cleanError(errMsg)
uiDispatch({type: 'set-error', value: cleanError(errMsg)}) const isHandleError = error.toLowerCase().includes('handle')
throw e
} uiDispatch({type: 'set-processing', value: false})
uiDispatch({type: 'set-error', value: cleanError(errMsg)})
uiDispatch({type: 'set-step', value: isHandleError ? 2 : 1})
}
},
[
uiState.email,
uiState.password,
uiState.handle,
uiState.isCaptchaRequired,
uiState.serviceUrl,
uiState.userDomain,
uiState.inviteCode,
uiState.birthDate,
uiDispatch,
_,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
],
)
} }
export function is13(state: CreateAccountState) { export function is13(state: CreateAccountState) {
@@ -269,22 +247,6 @@ function createReducer({_}: {_: I18nContext['_']}) {
case 'set-password': { case 'set-password': {
return compute({...state, password: action.value}) return compute({...state, password: action.value})
} }
case 'set-phone-country': {
return compute({...state, phoneCountry: action.value})
}
case 'set-verification-phone': {
return compute({
...state,
verificationPhone: action.value,
hasRequestedVerificationCode: false,
})
}
case 'set-verification-code': {
return compute({...state, verificationCode: action.value.trim()})
}
case 'set-has-requested-verification-code': {
return compute({...state, hasRequestedVerificationCode: action.value})
}
case 'set-handle': { case 'set-handle': {
return compute({...state, handle: action.value}) return compute({...state, handle: action.value})
} }
@@ -302,18 +264,10 @@ function createReducer({_}: {_: I18nContext['_']}) {
}) })
} }
} }
let increment = 1 return compute({...state, error: '', step: state.step + 1})
if (state.step === 1 && !state.isPhoneVerificationRequired) {
increment = 2
}
return compute({...state, error: '', step: state.step + increment})
} }
case 'back': { case 'back': {
let decrement = 1 return compute({...state, error: '', step: state.step - 1})
if (state.step === 3 && !state.isPhoneVerificationRequired) {
decrement = 2
}
return compute({...state, error: '', step: state.step - decrement})
} }
} }
} }
@@ -329,22 +283,16 @@ function compute(state: CreateAccountState): CreateAccountState {
!!state.password !!state.password
} else if (state.step === 2) { } else if (state.step === 2) {
canNext = canNext =
!state.isPhoneVerificationRequired || !!state.handle && validateHandle(state.handle, state.userDomain).overall
(!!state.verificationPhone &&
isValidVerificationCode(state.verificationCode))
} else if (state.step === 3) { } else if (state.step === 3) {
canNext = !!state.handle // Step 3 will automatically redirect as soon as the captcha completes
canNext = false
} }
return { return {
...state, ...state,
canBack: state.step > 1, canBack: state.step > 1,
canNext, canNext,
isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
isPhoneVerificationRequired: isCaptchaRequired: !!state.serviceDescription?.phoneVerificationRequired,
!!state.serviceDescription?.phoneVerificationRequired,
} }
} }
function isValidVerificationCode(str: string): boolean {
return /[0-9]{6}/.test(str)
}
+3 -3
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {PROD_SERVICE} from 'lib/constants' import {BSKY_SERVICE} from 'lib/constants'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {atoms as a, useBreakpoints, useTheme} from '#/alf'
@@ -26,7 +26,7 @@ export function ServerInputDialog({
const [pdsAddressHistory, setPdsAddressHistory] = React.useState<string[]>( const [pdsAddressHistory, setPdsAddressHistory] = React.useState<string[]>(
persisted.get('pdsAddressHistory') || [], persisted.get('pdsAddressHistory') || [],
) )
const [fixedOption, setFixedOption] = React.useState([PROD_SERVICE]) const [fixedOption, setFixedOption] = React.useState([BSKY_SERVICE])
const [customAddress, setCustomAddress] = React.useState('') const [customAddress, setCustomAddress] = React.useState('')
const onClose = React.useCallback(() => { const onClose = React.useCallback(() => {
@@ -86,7 +86,7 @@ export function ServerInputDialog({
label="Preferences" label="Preferences"
values={fixedOption} values={fixedOption}
onChange={setFixedOption}> onChange={setFixedOption}>
<ToggleButton.Button name={PROD_SERVICE} label={_(msg`Bluesky`)}> <ToggleButton.Button name={BSKY_SERVICE} label={_(msg`Bluesky`)}>
{_(msg`Bluesky`)} {_(msg`Bluesky`)}
</ToggleButton.Button> </ToggleButton.Button>
<ToggleButton.Button <ToggleButton.Button
+4 -13
View File
@@ -46,7 +46,7 @@ export function FeedPage({
renderEmptyState: () => JSX.Element renderEmptyState: () => JSX.Element
renderEndOfFeed?: () => JSX.Element renderEndOfFeed?: () => JSX.Element
}) { }) {
const {isSandbox, hasSession} = useSession() const {hasSession} = useSession()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation() const navigation = useNavigation()
@@ -119,7 +119,7 @@ export function FeedPage({
style={[pal.text, {fontWeight: 'bold'}]} style={[pal.text, {fontWeight: 'bold'}]}
text={ text={
<> <>
{isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} Bluesky{' '}
{hasNew && ( {hasNew && (
<View <View
style={{ style={{
@@ -138,7 +138,7 @@ export function FeedPage({
{hasSession && ( {hasSession && (
<TextLink <TextLink
type="title-lg" type="title-lg"
href="/settings/home-feed" href="/settings/following-feed"
style={{fontWeight: 'bold'}} style={{fontWeight: 'bold'}}
accessibilityLabel={_(msg`Feed Preferences`)} accessibilityLabel={_(msg`Feed Preferences`)}
accessibilityHint="" accessibilityHint=""
@@ -154,16 +154,7 @@ export function FeedPage({
) )
} }
return <></> return <></>
}, [ }, [isDesktop, pal.view, pal.text, pal.textLight, hasNew, _, hasSession])
isDesktop,
pal.view,
pal.text,
pal.textLight,
hasNew,
_,
isSandbox,
hasSession,
])
return ( return (
<View testID={testID} style={s.h100pct}> <View testID={testID} style={s.h100pct}>

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